diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..7d87cbfc6e419d8911169a7833cacb613f32b139 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,33 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +autodE/source/autode/common/hessians.pdf filter=lfs diff=lfs merge=lfs -text +autodE/source/autode/common/llogo.png filter=lfs diff=lfs merge=lfs -text +autodE/source/autode/common/logo.pages filter=lfs diff=lfs merge=lfs -text +autodE/source/autode/common/NEB.pdf filter=lfs diff=lfs merge=lfs -text +autodE/source/autode/common/thermochemistry.pdf filter=lfs diff=lfs merge=lfs -text +autodE/source/doc/common/adapt_surface_sn2.png filter=lfs diff=lfs merge=lfs -text +autodE/source/doc/common/claisen_neb_optimised.png filter=lfs diff=lfs merge=lfs -text +autodE/source/doc/common/conformers.png filter=lfs diff=lfs merge=lfs -text +autodE/source/doc/common/curtius_ts.png filter=lfs diff=lfs merge=lfs -text +autodE/source/doc/common/curtius.png filter=lfs diff=lfs merge=lfs -text +autodE/source/doc/common/DA_surface_interpolated.png filter=lfs diff=lfs merge=lfs -text +autodE/source/doc/common/DA_surface.png filter=lfs diff=lfs merge=lfs -text +autodE/source/doc/common/diels_alder_quickstart.png filter=lfs diff=lfs merge=lfs -text +autodE/source/doc/common/diels_alder.png filter=lfs diff=lfs merge=lfs -text +autodE/source/doc/common/functionalisation.png filter=lfs diff=lfs merge=lfs -text +autodE/source/doc/common/logo.png filter=lfs diff=lfs merge=lfs -text +autodE/source/doc/common/molfunc_functionalisation.png filter=lfs diff=lfs merge=lfs -text +autodE/source/doc/common/na_h2o_3_confomers.png filter=lfs diff=lfs merge=lfs -text +autodE/source/doc/common/OH_PES_relaxed.png filter=lfs diff=lfs merge=lfs -text +autodE/source/doc/common/OH_PES_unrelaxed_DFT.png filter=lfs diff=lfs merge=lfs -text +autodE/source/doc/common/OH_PES_unrelaxed.png filter=lfs diff=lfs merge=lfs -text +autodE/source/doc/common/opt_convergence_3500_ORCA.png filter=lfs diff=lfs merge=lfs -text +autodE/source/doc/common/sn2_image.png filter=lfs diff=lfs merge=lfs -text +autodE/source/doc/common/vaskas_conformers.png filter=lfs diff=lfs merge=lfs -text +autodE/source/doc/common/vaskas.png filter=lfs diff=lfs merge=lfs -text +autodE/source/doc/common/water_opt_energy.png filter=lfs diff=lfs merge=lfs -text +autodE/source/doc/common/water_shift.png filter=lfs diff=lfs merge=lfs -text +autodE/source/doc/common/water_trimer_expl.png filter=lfs diff=lfs merge=lfs -text +autodE/source/doc/common/water_trimer.png filter=lfs diff=lfs merge=lfs -text +autodE/source/doc/common/XY_bde_XTB.png filter=lfs diff=lfs merge=lfs -text diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..dee79449f7ae3ca93732427c60a4276247b2206b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.10 + +RUN useradd -m -u 1000 user && python -m pip install --upgrade pip +USER user +ENV PATH="/home/user/.local/bin:$PATH" + +WORKDIR /app + +COPY --chown=user ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt + +COPY --chown=user . /app +ENV MCP_TRANSPORT=http +ENV MCP_PORT=7860 + +EXPOSE 7860 + +CMD ["python", "autodE/mcp_output/start_mcp.py"] diff --git a/README.md b/README.md index 841e2dc7d2376b27588b8d3a54b041bbc8892ad1..015677c071924667622a39ee61decad5c2731a1f 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,32 @@ --- -title: AutodE -emoji: 😻 -colorFrom: red -colorTo: yellow +title: Autode MCP +emoji: 🤖 +colorFrom: blue +colorTo: purple sdk: docker +sdk_version: "4.26.0" +app_file: app.py pinned: false --- -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +# Autode MCP Service + +Auto-generated MCP service for autodE. + +## Usage + +``` +https://None-autodE-mcp.hf.space/mcp +``` + +## Connect with Cursor + +```json +{ + "mcpServers": { + "autodE": { + "url": "https://None-autodE-mcp.hf.space/mcp" + } + } +} +``` diff --git a/app.py b/app.py new file mode 100644 index 0000000000000000000000000000000000000000..0198e1d3adedf79ad01d50a88c9f5f8370f0fe78 --- /dev/null +++ b/app.py @@ -0,0 +1,45 @@ +from fastapi import FastAPI +import os +import sys + +mcp_plugin_path = os.path.join(os.path.dirname(__file__), "autodE", "mcp_output", "mcp_plugin") +sys.path.insert(0, mcp_plugin_path) + +app = FastAPI( + title="Autode MCP Service", + description="Auto-generated MCP service for autodE", + version="1.0.0" +) + +@app.get("/") +def root(): + return { + "service": "Autode MCP Service", + "version": "1.0.0", + "status": "running", + "transport": os.environ.get("MCP_TRANSPORT", "http") + } + +@app.get("/health") +def health_check(): + return {"status": "healthy", "service": "autodE MCP"} + +@app.get("/tools") +def list_tools(): + try: + from mcp_service import create_app + mcp_app = create_app() + tools = [] + for tool_name, tool_func in mcp_app.tools.items(): + tools.append({ + "name": tool_name, + "description": tool_func.__doc__ or "No description available" + }) + return {"tools": tools} + except Exception as e: + return {"error": f"Failed to load tools: {str(e)}"} + +if __name__ == "__main__": + import uvicorn + port = int(os.environ.get("PORT", 7860)) + uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/autodE/mcp_output/README_MCP.md b/autodE/mcp_output/README_MCP.md new file mode 100644 index 0000000000000000000000000000000000000000..ad9dd4b6948114bec102a3a9f14e35ad68a3e36c --- /dev/null +++ b/autodE/mcp_output/README_MCP.md @@ -0,0 +1,59 @@ +# autodE + +## Project Introduction + +autodE is a Python module designed for the automated calculation of reaction profiles from SMILES strings of reactants and products. It automates the complex process of finding transition states, performing conformer searches, and generating complete reaction energy profiles using quantum chemical calculations. The core functionalities include handling atomic properties, managing quantum chemical calculations, defining and analyzing chemical reactions, and optimizing transition states. + +## Installation Method + +To install autodE, ensure you have Python installed and then use the following pip command: + +``` +pip install autodE +``` + +### Dependencies + +autodE requires the following dependencies: +- Required: numpy, scipy, ase +- Optional: matplotlib, pandas + +Ensure these dependencies are installed in your environment. + +## Quick Start + +Here's a simple example to get started with autodE: + +1. Import autodE and define reactants and products using SMILES strings. +2. Create a Reaction object and calculate the reaction profile. + +``` +import autode as ade + +# Define reactants and products +reactant = ade.Reactant(smiles='CC[H]') +product = ade.Product(smiles='C[H]C') + +# Create reaction and calculate profile +reaction = ade.Reaction(reactant, product, name='1-2_shift') +reaction.calculate_reaction_profile() +``` + +This high-level interface abstracts the complexity of transition state location, conformer generation, and thermochemical analysis while providing full control over the underlying quantum chemical calculations. + +## Available Tools and Endpoints List + +- **autode-calculate**: Runs a quantum chemical calculation using specified parameters. +- **autode-reaction**: Analyzes a chemical reaction and computes its properties. + +## Common Issues and Notes + +- Ensure all required dependencies are installed to avoid import errors. +- The performance of calculations can be affected by the computational resources available. Adjust the number of cores and memory settings in the configuration if necessary. +- If using optional dependencies like matplotlib or pandas, ensure they are installed for enhanced functionality such as plotting and data manipulation. + +## Reference Links or Documentation + +For more detailed information, visit the autodE GitHub repository: [autodE GitHub](https://github.com/duartegroup/autodE) + +For comprehensive documentation, refer to the autodE documentation available in the repository. \ No newline at end of file diff --git a/autodE/mcp_output/analysis.json b/autodE/mcp_output/analysis.json new file mode 100644 index 0000000000000000000000000000000000000000..d174eec4d5c806a3a22a474f80216f4f071d4783 --- /dev/null +++ b/autodE/mcp_output/analysis.json @@ -0,0 +1,992 @@ +{ + "summary": { + "repository_url": "https://github.com/duartegroup/autodE", + "summary": "Imported via zip fallback, file count: 275", + "file_tree": { + ".github/ISSUE_TEMPLATE/bug_report.md": { + "size": 567 + }, + ".github/ISSUE_TEMPLATE/feature_request.md": { + "size": 194 + }, + ".github/pull_request_template.md": { + "size": 273 + }, + ".github/workflows/catch.yml": { + "size": 651 + }, + ".github/workflows/deploy.yml": { + "size": 824 + }, + ".github/workflows/lint.yml": { + "size": 683 + }, + ".github/workflows/pytest.yml": { + "size": 1191 + }, + ".github/workflows/pytest_cov.yml": { + "size": 1396 + }, + ".pre-commit-config.yaml": { + "size": 446 + }, + "CONTRIBUTING.md": { + "size": 228 + }, + "LICENSE.md": { + "size": 1067 + }, + "README.md": { + "size": 4804 + }, + "autode/__init__.py": { + "size": 1744 + }, + "autode/atoms.py": { + "size": 46059 + }, + "autode/bond_rearrangement.py": { + "size": 28652 + }, + "autode/bonds.py": { + "size": 2554 + }, + "autode/bracket/__init__.py": { + "size": 70 + }, + "autode/bracket/base.py": { + "size": 11755 + }, + "autode/bracket/dhs.py": { + "size": 28076 + }, + "autode/bracket/ieip.py": { + "size": 22489 + }, + "autode/bracket/imagepair.py": { + "size": 21276 + }, + "autode/calculations/__init__.py": { + "size": 236 + }, + "autode/calculations/calculation.py": { + "size": 10489 + }, + "autode/calculations/executors.py": { + "size": 18044 + }, + "autode/calculations/input.py": { + "size": 2394 + }, + "autode/calculations/output.py": { + "size": 2371 + }, + "autode/calculations/types.py": { + "size": 167 + }, + "autode/config.py": { + "size": 19291 + }, + "autode/conformers/__init__.py": { + "size": 141 + }, + "autode/conformers/conf_gen.py": { + "size": 16976 + }, + "autode/conformers/conformer.py": { + "size": 5866 + }, + "autode/conformers/conformers.py": { + "size": 12027 + }, + "autode/constants.py": { + "size": 965 + }, + "autode/constraints.py": { + "size": 5418 + }, + "autode/exceptions.py": { + "size": 4054 + }, + "autode/ext/CMakeLists.txt": { + "size": 941 + }, + "autode/ext/README.md": { + "size": 374 + }, + "autode/ext/__init__.py": { + "size": 0 + }, + "autode/geom.py": { + "size": 9283 + }, + "autode/hessians.py": { + "size": 23080 + }, + "autode/input_output.py": { + "size": 5343 + }, + "autode/log/__init__.py": { + "size": 57 + }, + "autode/log/log.py": { + "size": 2015 + }, + "autode/log/methods.py": { + "size": 1210 + }, + "autode/methods.py": { + "size": 4468 + }, + "autode/mol_graphs.py": { + "size": 24611 + }, + "autode/neb/__init__.py": { + "size": 96 + }, + "autode/neb/ci.py": { + "size": 3882 + }, + "autode/neb/idpp.py": { + "size": 5325 + }, + "autode/neb/neb.py": { + "size": 1899 + }, + "autode/neb/original.py": { + "size": 25297 + }, + "autode/opt/__init__.py": { + "size": 164 + }, + "autode/opt/coordinates/__init__.py": { + "size": 184 + }, + "autode/opt/coordinates/_autodiff.py": { + "size": 24107 + }, + "autode/opt/coordinates/base.py": { + "size": 14924 + }, + "autode/opt/coordinates/cartesian.py": { + "size": 4114 + }, + "autode/opt/coordinates/dic.py": { + "size": 18255 + }, + "autode/opt/coordinates/dimer.py": { + "size": 8581 + }, + "autode/opt/coordinates/internals.py": { + "size": 19160 + }, + "autode/opt/coordinates/primitives.py": { + "size": 19426 + }, + "autode/opt/optimisers/__init__.py": { + "size": 565 + }, + "autode/opt/optimisers/base.py": { + "size": 43844 + }, + "autode/opt/optimisers/crfo.py": { + "size": 9294 + }, + "autode/opt/optimisers/dimer.py": { + "size": 13546 + }, + "autode/opt/optimisers/hessian_update.py": { + "size": 17503 + }, + "autode/opt/optimisers/prfo.py": { + "size": 4780 + }, + "autode/opt/optimisers/qa.py": { + "size": 4388 + }, + "autode/opt/optimisers/rfo.py": { + "size": 5307 + }, + "autode/opt/optimisers/steepest_descent.py": { + "size": 1992 + }, + "autode/opt/optimisers/utils.py": { + "size": 5039 + }, + "autode/path/__init__.py": { + "size": 117 + }, + "autode/path/adaptive.py": { + "size": 12943 + }, + "autode/path/interpolation.py": { + "size": 8476 + }, + "autode/path/path.py": { + "size": 5925 + }, + "autode/pes/__init__.py": { + "size": 139 + }, + "autode/pes/mep.py": { + "size": 1239 + }, + "autode/pes/pes_nd.py": { + "size": 29328 + }, + "autode/pes/reactive.py": { + "size": 13627 + }, + "autode/pes/relaxed.py": { + "size": 7520 + }, + "autode/pes/unrelaxed.py": { + "size": 4701 + }, + "autode/plotting.py": { + "size": 16231 + }, + "autode/point_charges.py": { + "size": 1084 + }, + "autode/reactions/__init__.py": { + "size": 150 + }, + "autode/reactions/multistep.py": { + "size": 7474 + }, + "autode/reactions/reaction.py": { + "size": 32095 + }, + "autode/reactions/reaction_types.py": { + "size": 2720 + }, + "autode/smiles/__init__.py": { + "size": 115 + }, + "autode/smiles/angles.py": { + "size": 9039 + }, + "autode/smiles/atom_types.py": { + "size": 12856 + }, + "autode/smiles/base.py": { + "size": 8439 + }, + "autode/smiles/builder.py": { + "size": 36448 + }, + "autode/smiles/parser.py": { + "size": 18540 + }, + "autode/smiles/smiles.py": { + "size": 5932 + }, + "autode/solvent/__init__.py": { + "size": 211 + }, + "autode/solvent/explicit_solvent.py": { + "size": 8622 + }, + "autode/solvent/solvents.py": { + "size": 65287 + }, + "autode/species/__init__.py": { + "size": 522 + }, + "autode/species/complex.py": { + "size": 15627 + }, + "autode/species/molecule.py": { + "size": 9914 + }, + "autode/species/species.py": { + "size": 54754 + }, + "autode/substitution.py": { + "size": 10025 + }, + "autode/thermochemistry/__init__.py": { + "size": 177 + }, + "autode/thermochemistry/igm.py": { + "size": 17425 + }, + "autode/thermochemistry/symmetry.py": { + "size": 8040 + }, + "autode/transition_states/__init__.py": { + "size": 258 + }, + "autode/transition_states/base.py": { + "size": 19578 + }, + "autode/transition_states/lib/template0.txt": { + "size": 497 + }, + "autode/transition_states/locate_tss.py": { + "size": 12006 + }, + "autode/transition_states/templates.py": { + "size": 14335 + }, + "autode/transition_states/transition_state.py": { + "size": 13849 + }, + "autode/transition_states/transition_states.py": { + "size": 863 + }, + "autode/transition_states/truncation.py": { + "size": 11766 + }, + "autode/transition_states/ts_guess.py": { + "size": 8954 + }, + "autode/units.py": { + "size": 7919 + }, + "autode/utils.py": { + "size": 22566 + }, + "autode/values.py": { + "size": 23224 + }, + "autode/wrappers/G09.py": { + "size": 24012 + }, + "autode/wrappers/G16.py": { + "size": 503 + }, + "autode/wrappers/MOPAC.py": { + "size": 12114 + }, + "autode/wrappers/NWChem.py": { + "size": 15218 + }, + "autode/wrappers/ORCA.py": { + "size": 20640 + }, + "autode/wrappers/QChem.py": { + "size": 20416 + }, + "autode/wrappers/XTB.py": { + "size": 14414 + }, + "autode/wrappers/__init__.py": { + "size": 0 + }, + "autode/wrappers/keywords/__init__.py": { + "size": 1238 + }, + "autode/wrappers/keywords/basis_sets.py": { + "size": 1051 + }, + "autode/wrappers/keywords/dispersion.py": { + "size": 226 + }, + "autode/wrappers/keywords/functionals.py": { + "size": 662 + }, + "autode/wrappers/keywords/implicit_solvent_types.py": { + "size": 358 + }, + "autode/wrappers/keywords/keywords.py": { + "size": 19880 + }, + "autode/wrappers/keywords/ri.py": { + "size": 146 + }, + "autode/wrappers/keywords/wf.py": { + "size": 92 + }, + "autode/wrappers/methods.py": { + "size": 10180 + }, + "doc/README.md": { + "size": 318 + }, + "doc/common/DA_2d.py": { + "size": 368 + }, + "doc/common/DA_2d_interp.py": { + "size": 134 + }, + "doc/common/OH_PES_relaxed.py": { + "size": 614 + }, + "doc/common/OH_PES_unrelaxed.py": { + "size": 667 + }, + "doc/common/OH_PES_unrelaxed_DFT.py": { + "size": 734 + }, + "doc/common/XY_bde_XTB.py": { + "size": 2167 + }, + "doc/common/claisen_cineb.py": { + "size": 505 + }, + "doc/common/curtius.py": { + "size": 169 + }, + "doc/common/methane_molfunc.py": { + "size": 409 + }, + "doc/common/na_h2o_3.py": { + "size": 460 + }, + "doc/common/nci_FF_example.py": { + "size": 3894 + }, + "doc/common/rmsd.py": { + "size": 2490 + }, + "doc/common/vaskas_conformers.py": { + "size": 1013 + }, + "doc/common/water_trimer.py": { + "size": 681 + }, + "doc/conf.py": { + "size": 1617 + }, + "doc/requirements.txt": { + "size": 25 + }, + "examples/README.md": { + "size": 343 + }, + "examples/diels_alder.py": { + "size": 134 + }, + "examples/sn2.py": { + "size": 159 + }, + "examples/tutorials/a_atoms.py": { + "size": 995 + }, + "examples/tutorials/b_atom_collections.py": { + "size": 655 + }, + "examples/tutorials/c_species.py": { + "size": 980 + }, + "examples/tutorials/d_solvated_species.py": { + "size": 741 + }, + "examples/tutorials/e_molecules.py": { + "size": 1409 + }, + "examples/tutorials/f_molecule_io.py": { + "size": 1304 + }, + "examples/tutorials/g_conformers.py": { + "size": 1427 + }, + "examples/tutorials/h_configuration.py": { + "size": 1321 + }, + "examples/tutorials/i_constrained_opt.py": { + "size": 1475 + }, + "examples/tutorials/j_NEB.py": { + "size": 965 + }, + "examples/tutorials/k_1d_pes.py": { + "size": 755 + }, + "examples/tutorials/l_2d_pes.py": { + "size": 860 + }, + "examples/tutorials/m_thermochem.py": { + "size": 867 + }, + "examples/tutorials/n_normal_modes.py": { + "size": 921 + }, + "examples/tutorials/o_transition_states.py": { + "size": 1088 + }, + "examples/tutorials/p_reaction_profile1.py": { + "size": 718 + }, + "examples/tutorials/q_reaction_profile2.py": { + "size": 1065 + }, + "examples/tutorials/r_hessians.py": { + "size": 547 + }, + "examples/tutorials/s_logging.py": { + "size": 543 + }, + "examples/tutorials/t_identity_reactions.py": { + "size": 842 + }, + "examples/tutorials/u_reaction_profile_reload.py": { + "size": 863 + }, + "pyproject.toml": { + "size": 366 + }, + "requirements.txt": { + "size": 64 + }, + "setup.py": { + "size": 2005 + }, + "tests/README.md": { + "size": 1601 + }, + "tests/__init__.py": { + "size": 0 + }, + "tests/benchmark.py": { + "size": 11598 + }, + "tests/conftest.py": { + "size": 740 + }, + "tests/data/benchmark/ADE_SM.txt": { + "size": 106 + }, + "tests/data/benchmark/ADE_SO.txt": { + "size": 482 + }, + "tests/data/test_subprocess.py": { + "size": 21 + }, + "tests/requirements.txt": { + "size": 34 + }, + "tests/test_atoms.py": { + "size": 13407 + }, + "tests/test_attack.py": { + "size": 2151 + }, + "tests/test_bond_rearrangement.py": { + "size": 17958 + }, + "tests/test_bracket/__init__.py": { + "size": 0 + }, + "tests/test_bracket/test_dhs.py": { + "size": 11627 + }, + "tests/test_bracket/test_ieip.py": { + "size": 5293 + }, + "tests/test_bracket/test_imagepair.py": { + "size": 10123 + }, + "tests/test_calculation.py": { + "size": 16185 + }, + "tests/test_comp_methods.py": { + "size": 741 + }, + "tests/test_complex.py": { + "size": 7841 + }, + "tests/test_conf_gen.py": { + "size": 10342 + }, + "tests/test_config.py": { + "size": 3871 + }, + "tests/test_conformers.py": { + "size": 11906 + }, + "tests/test_const_opt.py": { + "size": 748 + }, + "tests/test_constraints.py": { + "size": 3203 + }, + "tests/test_examples.py": { + "size": 2831 + }, + "tests/test_explicit_solvent.py": { + "size": 3735 + }, + "tests/test_g16.py": { + "size": 154 + }, + "tests/test_geom.py": { + "size": 3074 + }, + "tests/test_graphs.py": { + "size": 12383 + }, + "tests/test_hessian.py": { + "size": 30049 + }, + "tests/test_import.py": { + "size": 940 + }, + "tests/test_input_output.py": { + "size": 3916 + }, + "tests/test_locate_tss.py": { + "size": 2687 + }, + "tests/test_log.py": { + "size": 856 + }, + "tests/test_methods.py": { + "size": 3515 + }, + "tests/test_molecule.py": { + "size": 8215 + }, + "tests/test_multistep.py": { + "size": 4088 + }, + "tests/test_nci_complex.py": { + "size": 1044 + }, + "tests/test_neb.py": { + "size": 11578 + }, + "tests/test_opt/__init__.py": { + "size": 0 + }, + "tests/test_opt/molecules.py": { + "size": 2438 + }, + "tests/test_opt/setup.py": { + "size": 493 + }, + "tests/test_opt/test_autodiff.py": { + "size": 6118 + }, + "tests/test_opt/test_coordiantes.py": { + "size": 31278 + }, + "tests/test_opt/test_crfo.py": { + "size": 13397 + }, + "tests/test_opt/test_dimer.py": { + "size": 7715 + }, + "tests/test_opt/test_hessian_update.py": { + "size": 9860 + }, + "tests/test_opt/test_opt.py": { + "size": 18254 + }, + "tests/test_opt/test_opt_utils.py": { + "size": 2978 + }, + "tests/test_opt/test_prfo.py": { + "size": 3741 + }, + "tests/test_opt/test_qa.py": { + "size": 3301 + }, + "tests/test_opt/test_rfo.py": { + "size": 3062 + }, + "tests/test_path.py": { + "size": 9185 + }, + "tests/test_pes/__init__.py": { + "size": 0 + }, + "tests/test_pes/sample_pes.py": { + "size": 864 + }, + "tests/test_pes/test_base_class.py": { + "size": 3058 + }, + "tests/test_pes/test_calculate.py": { + "size": 1641 + }, + "tests/test_pes/test_load_save.py": { + "size": 2097 + }, + "tests/test_pes/test_mep.py": { + "size": 2958 + }, + "tests/test_pes/test_points.py": { + "size": 6631 + }, + "tests/test_pes/test_relaxed.py": { + "size": 6719 + }, + "tests/test_pes/test_rs.py": { + "size": 6049 + }, + "tests/test_pes/test_unrelaxed.py": { + "size": 2271 + }, + "tests/test_plotting.py": { + "size": 7230 + }, + "tests/test_point_charge.py": { + "size": 780 + }, + "tests/test_qrc.py": { + "size": 1362 + }, + "tests/test_rb_min.py": { + "size": 1579 + }, + "tests/test_reaction_class.py": { + "size": 17661 + }, + "tests/test_reaction_with_complexes.py": { + "size": 1233 + }, + "tests/test_reactions.py": { + "size": 1203 + }, + "tests/test_smiles_base.py": { + "size": 1190 + }, + "tests/test_smiles_builder.py": { + "size": 21622 + }, + "tests/test_smiles_parser.py": { + "size": 14277 + }, + "tests/test_sn2prime.py": { + "size": 2722 + }, + "tests/test_solvents.py": { + "size": 2315 + }, + "tests/test_species.py": { + "size": 20762 + }, + "tests/test_substitution.py": { + "size": 2411 + }, + "tests/test_thermochem.py": { + "size": 10114 + }, + "tests/test_truncation.py": { + "size": 7249 + }, + "tests/test_ts/__init__.py": { + "size": 0 + }, + "tests/test_ts/test_mode_checking.py": { + "size": 3312 + }, + "tests/test_ts/test_ts_adapt_neb.py": { + "size": 2762 + }, + "tests/test_ts/test_ts_base.py": { + "size": 1668 + }, + "tests/test_ts/test_ts_guess.py": { + "size": 370 + }, + "tests/test_ts/test_ts_template.py": { + "size": 7961 + }, + "tests/test_units.py": { + "size": 753 + }, + "tests/test_utils.py": { + "size": 12326 + }, + "tests/test_value.py": { + "size": 7621 + }, + "tests/test_values.py": { + "size": 3635 + }, + "tests/test_wrappers/__init__.py": { + "size": 0 + }, + "tests/test_wrappers/test_gaussian.py": { + "size": 13535 + }, + "tests/test_wrappers/test_keywords.py": { + "size": 6887 + }, + "tests/test_wrappers/test_mopac.py": { + "size": 9087 + }, + "tests/test_wrappers/test_nwchem.py": { + "size": 7848 + }, + "tests/test_wrappers/test_orca.py": { + "size": 12660 + }, + "tests/test_wrappers/test_qchem.py": { + "size": 16267 + }, + "tests/test_wrappers/test_wrappers.py": { + "size": 344 + }, + "tests/test_wrappers/test_xtb.py": { + "size": 12198 + }, + "tests/testutils.py": { + "size": 1682 + } + }, + "processed_by": "zip_fallback", + "success": true + }, + "structure": { + "packages": [ + "source.autode", + "source.autode.bracket", + "source.autode.calculations", + "source.autode.conformers", + "source.autode.ext", + "source.autode.log", + "source.autode.neb", + "source.autode.opt", + "source.autode.path", + "source.autode.pes", + "source.autode.reactions", + "source.autode.smiles", + "source.autode.solvent", + "source.autode.species", + "source.autode.thermochemistry", + "source.autode.transition_states", + "source.autode.wrappers", + "source.tests", + "source.tests.test_bracket", + "source.tests.test_opt", + "source.tests.test_pes", + "source.tests.test_ts", + "source.tests.test_wrappers" + ] + }, + "dependencies": { + "has_environment_yml": false, + "has_requirements_txt": true, + "pyproject": true, + "setup_cfg": false, + "setup_py": true + }, + "entry_points": { + "imports": [], + "cli": [], + "modules": [] + }, + "llm_analysis": { + "core_modules": [ + { + "package": "source.autode.atoms", + "module": "atoms", + "functions": [ + "get_distance", + "get_angle" + ], + "classes": [ + "Atom", + "Atoms" + ], + "description": "Handles atomic properties and operations." + }, + { + "package": "source.autode.calculations", + "module": "calculation", + "functions": [ + "run_calculation", + "parse_output" + ], + "classes": [ + "Calculation", + "CalculationExecutor" + ], + "description": "Manages quantum chemical calculations and their execution." + }, + { + "package": "source.autode.reactions", + "module": "reaction", + "functions": [ + "find_reaction_path", + "calculate_reaction_energy" + ], + "classes": [ + "Reaction", + "ReactionPath" + ], + "description": "Defines and analyzes chemical reactions." + }, + { + "package": "source.autode.transition_states", + "module": "transition_state", + "functions": [ + "locate_ts", + "optimize_ts" + ], + "classes": [ + "TransitionState", + "TSOptimizer" + ], + "description": "Handles transition state search and optimization." + }, + { + "package": "source.autode.wrappers", + "module": "G09", + "functions": [ + "execute_g09", + "parse_g09_output" + ], + "classes": [ + "G09Wrapper" + ], + "description": "Interface for Gaussian09 quantum chemistry software." + } + ], + "cli_commands": [ + { + "name": "autode-calculate", + "module": "source.autode.calculations.calculation", + "description": "Runs a quantum chemical calculation using specified parameters." + }, + { + "name": "autode-reaction", + "module": "source.autode.reactions.reaction", + "description": "Analyzes a chemical reaction and computes its properties." + } + ], + "import_strategy": { + "primary": "import", + "fallback": "blackbox", + "confidence": 0.85 + }, + "dependencies": { + "required": [ + "numpy", + "scipy", + "ase" + ], + "optional": [ + "matplotlib", + "pandas" + ] + }, + "risk_assessment": { + "import_feasibility": 0.8, + "intrusiveness_risk": "medium", + "complexity": "complex" + } + }, + "deepwiki_analysis": { + "repo_url": "https://github.com/duartegroup/autodE", + "repo_name": "autodE", + "content": "duartegroup/autodE\nCore Architecture\nChemical Species and Atoms\nReactions and Bond Rearrangements\nConfiguration System\nTransition State Analysis\nTransition State Location Methods\nTS Validation and Optimization\nMolecular Graphs and Connectivity\nBracketing Methods\nElectronic Structure Interface\nMethod Wrappers\nCalculations and Executors\nKeywords and Thermochemistry\nGeometry Optimization\nCoordinate Systems\nOptimization Algorithms\nConformer Generation\nConformer Generation Algorithms\nConformer Management\nAdditional Systems\nSMILES Processing\nMolecular Truncation\nExplicit Solvation\nPlotting and Visualization\nUtilities and Development\nCore Utilities\nTesting and CI/CD\nautode/__init__.py\nautode/transition_states/templates.py\ndoc/changelog.rst\ndoc/config.rst\ndoc/index.rst\ndoc/install.rst\ndoc/troubleshooting.rst\nexamples/README.md\nPurpose and Scope\nautodE is a Python module designed for the automated calculation of reaction profiles from SMILES strings of reactants and products. This system automates the complex process of finding transition states, performing conformer searches, and generating complete reaction energy profiles using quantum chemical calculations.\nThis overview provides a high-level architectural understanding of autodE's core systems and their interactions. For detailed information about specific subsystems, seeCore Architecture,Transition State Analysis,Electronic Structure Interface, andGeometry Optimization.\nSources:README.md7-11doc/index.rst13-16autode/__init__.py1-71\nCore Workflow and Concepts\nautodE follows a double-ended search approach, starting from reactant and product structures to automatically locate transition states and generate reaction profiles. The typical workflow involves:\nInput Processing: Users provide reactants and products as SMILES strings or 3D structures\nBond Rearrangement Analysis: The system identifies which bonds form and break during the reaction\nTransition State Location: Multiple algorithms search for saddle points connecting reactants to products\nProfile Generation: Complete energy profiles are calculated with conformer searching and thermochemistry\nUser InputReactant/Product SMILESBond Rearrangement Analysisautode.reactions.bond_rearrangementTransition State Locationautode.transition_statesReaction Profile Generationautode.reactions.reactionTemplate Matchingautode.transition_states.templatesAdaptive Path Searchautode.pathNEB Calculationsautode.nebConformer Generationautode.conformersThermochemistryautode.thermochemistryFinal ResultsEnergy profiles & structures\nUser InputReactant/Product SMILES\nBond Rearrangement Analysisautode.reactions.bond_rearrangement\nTransition State Locationautode.transition_states\nReaction Profile Generationautode.reactions.reaction\nTemplate Matchingautode.transition_states.templates\nAdaptive Path Searchautode.path\nNEB Calculationsautode.neb\nConformer Generationautode.conformers\nThermochemistryautode.thermochemistry\nFinal ResultsEnergy profiles & structures\nSources:README.md41-50doc/changelog.rst756-784autode/reactions/reaction.py\nHigh-Level System Architecture\nThe autodE architecture consists of several interconnected layers that handle different aspects of the reaction profile calculation workflow:\nExternal ProgramsElectronic Structure InterfaceOptimization FrameworkTransition State EngineReaction Analysis EngineCore Chemical RepresentationUser InterfaceCommand Line InterfacePython APIautode.Reactionautode.MoleculeConfiguration Systemautode.config.ConfigChemical Speciesautode.species.Speciesautode.species.molecule.MoleculeAtomic Dataautode.atoms.Atomautode.atoms.AtomsCoordinate Systemsautode.opt.coordinatesReaction Objectsautode.reactions.reaction.ReactionBond Rearrangementsautode.reactions.bond_rearrangementMolecular Graphsautode.mol_graphsTS Locationautode.transition_statesTS Templatesautode.transition_states.templatesBracketing Methodsautode.bracketGeometry Optimizersautode.opt.optimisersNEB Methodsautode.nebPath Optimizationautode.pathCalculation Managerautode.calculations.CalculationMethod Wrappersautode.wrappersKeyword Managementautode.wrappers.keywordsORCAautode.wrappers.ORCAGaussianautode.wrappers.G09/G16XTBautode.wrappers.XTBMOPACautode.wrappers.MOPAC\nExternal Programs\nElectronic Structure Interface\nOptimization Framework\nTransition State Engine\nReaction Analysis Engine\nCore Chemical Representation\nUser Interface\nCommand Line Interface\nPython APIautode.Reactionautode.Molecule\nConfiguration Systemautode.config.Config\nChemical Speciesautode.species.Speciesautode.species.molecule.Molecule\nAtomic Dataautode.atoms.Atomautode.atoms.Atoms\nCoordinate Systemsautode.opt.coordinates\nReaction Objectsautode.reactions.reaction.Reaction\nBond Rearrangementsautode.reactions.bond_rearrangement\nMolecular Graphsautode.mol_graphs\nTS Locationautode.transition_states\nTS Templatesautode.transition_states.templates\nBracketing Methodsautode.bracket\nGeometry Optimizersautode.opt.optimisers\nNEB Methodsautode.neb\nPath Optimizationautode.path\nCalculation Managerautode.calculations.Calculation\nMethod Wrappersautode.wrappers\nKeyword Managementautode.wrappers.keywords\nORCAautode.wrappers.ORCA\nGaussianautode.wrappers.G09/G16\nXTBautode.wrappers.XTB\nMOPACautode.wrappers.MOPAC\nSources:autode/__init__.py44-71setup.py37-57doc/changelog.rst overall system diagrams\nKey Components\nChemical Species and Data Structures\nThe foundation of autodE rests on robust chemical data structures that represent atoms, molecules, and their properties:\nautode.atoms.Atom\nautode.species.molecule.Molecule\nautode.species.molecule.Reactant\nautode.species.molecule.Product\nautode.species.complex.NCIComplex\nSources:autode/__init__.py14-16autode/species/autode/atoms.py\nReaction Processing\nThe reaction analysis system identifies chemical changes and guides transition state searches:\nautode.reactions.reaction.Reactionautode.reactions.bond_rearrangement.BondRearrangementautode.mol_graphs.MolecularGraphautode.transition_states.ts_guess.TSguessautode.transition_states.transition_state.TransitionState\nautode.reactions.reaction.Reaction\nautode.reactions.bond_rearrangement.BondRearrangement\nautode.mol_graphs.MolecularGraph\nautode.transition_states.ts_guess.TSguess\nautode.transition_states.transition_state.TransitionState\nSources:autode/reactions/autode/mol_graphs/autode/transition_states/\nElectronic Structure Integration\nautodE provides a unified interface to multiple quantum chemistry packages through method wrappers:\nautode.wrappers.ORCA\nautode.wrappers.G09\nautode.wrappers.G16\nautode.wrappers.XTB\nautode.wrappers.MOPAC\nautode.wrappers.NWChem\nautode.wrappers.QChem\nSources:README.md15-24autode/wrappers/doc/install.rst10-22\nConfiguration and Extensibility\nThe system is highly configurable through theautode.config.Configclass, which manages:\nautode.config.Config\nElectronic structure method selection and keywords\nOptimization parameters and convergence criteria\nParallel execution settings\nTemplate libraries for transition state finding\nLogging and output control\nautode.config.ConfigMethod ConfigurationConfig.ORCA, Config.XTB, etc.Keyword ManagementConfig.keywordsCore Settingsn_cores, max_core, etc.Optimization Keywordsautode.wrappers.keywords.OptKeywordsSingle Point Keywordsautode.wrappers.keywords.SinglePointKeywordsHessian Keywordsautode.wrappers.keywords.HessianKeywords\nautode.config.Config\nMethod ConfigurationConfig.ORCA, Config.XTB, etc.\nKeyword ManagementConfig.keywords\nCore Settingsn_cores, max_core, etc.\nOptimization Keywordsautode.wrappers.keywords.OptKeywords\nSingle Point Keywordsautode.wrappers.keywords.SinglePointKeywords\nHessian Keywordsautode.wrappers.keywords.HessianKeywords\nSources:autode/config.pydoc/config.rst1-217autode/wrappers/keywords/\nUsage Patterns\nThe primary usage pattern involves creatingReactionobjects from reactants and products, then invoking the automated workflow:\nimportautodeasade# Define reactants and productsreactant = ade.Reactant(smiles='CCHii[H]')product = ade.Product(smiles='CHiiC')# Create reaction and calculate profilereaction = ade.Reaction(reactant, product, name='1-2_shift')reaction.calculate_reaction_profile()\nimportautodeasade# Define reactants and productsreactant = ade.Reactant(smiles='CCHii[H]')product = ade.Product(smiles='CHiiC')# Create reaction and calculate profilereaction = ade.Reaction(reactant, product, name='1-2_shift')reaction.calculate_reaction_profile()\nimportautodeasade# Define reactants and productsreactant = ade.Reactant(smiles='CCHii[H]')product = ade.Product(smiles='CHiiC')# Create reaction and calculate profilereaction = ade.Reaction(reactant, product, name='1-2_shift')reaction.calculate_reaction_profile()\nThis high-level interface abstracts the complexity of transition state location, conformer generation, and thermochemical analysis while providing full control over the underlying quantum chemical calculations.\nSources:README.md41-50examples/README.md1-8doc/quickstart.rst examples\nRefresh this wiki\nOn this page\nPurpose and Scope\nCore Workflow and Concepts\nHigh-Level System Architecture\nKey Components\nChemical Species and Data Structures\nReaction Processing\nElectronic Structure Integration\nConfiguration and Extensibility\nUsage Patterns", + "model": "gpt-4o-2024-08-06", + "source": "selenium", + "success": true + }, + "deepwiki_options": { + "enabled": true, + "model": "gpt-4o-2024-08-06" + }, + "risk": { + "import_feasibility": 0.8, + "intrusiveness_risk": "medium", + "complexity": "complex" + } +} \ No newline at end of file diff --git a/autodE/mcp_output/diff_report.md b/autodE/mcp_output/diff_report.md new file mode 100644 index 0000000000000000000000000000000000000000..199823b204aa4e82dd954a3171d1f33ae985f2f4 --- /dev/null +++ b/autodE/mcp_output/diff_report.md @@ -0,0 +1,68 @@ +# autodE Project Difference Report + +**Repository:** autodE +**Project Type:** Python Library +**Report Date:** February 4, 2026 +**Time:** 13:19:49 +**Intrusiveness:** None +**Workflow Status:** Success +**Test Status:** Failed + +## Project Overview + +The autodE project is a Python library designed to provide basic functionality for computational chemistry tasks. The library aims to simplify the process of setting up and running quantum chemistry calculations, making it accessible to a broader audience of researchers and developers. + +## Difference Analysis + +### New Files + +Since the last update, the autodE project has introduced 8 new files. These files likely contain new features or enhancements to existing functionalities. However, no existing files have been modified, indicating that the new additions are likely standalone features or modules. + +### Modified Files + +There are no modified files in this update, suggesting that the existing codebase remains unchanged. This could imply that the new files are designed to extend the library's capabilities without altering the current functionality. + +### Workflow and Test Status + +- **Workflow Status:** The workflow status is marked as successful, indicating that the integration and deployment processes were executed without errors. +- **Test Status:** The test status is marked as failed, which suggests that the new additions may have introduced issues or that the existing test suite does not cover the new functionalities adequately. + +## Technical Analysis + +The introduction of 8 new files without modifications to existing ones suggests a modular approach to extending the library. This approach minimizes the risk of introducing bugs into the existing codebase but requires thorough testing to ensure compatibility and functionality of the new modules. + +The failure in the test status indicates potential issues that need to be addressed. These could range from integration problems with the new files to inadequacies in the test coverage for the new functionalities. + +## Recommendations and Improvements + +1. **Enhance Test Coverage:** + - Develop comprehensive test cases for the new files to ensure they function as expected. + - Review and update the existing test suite to include scenarios that involve interactions between the new and existing functionalities. + +2. **Code Review and Refactoring:** + - Conduct a thorough code review of the new files to identify any potential issues or areas for optimization. + - Consider refactoring the new code to improve readability and maintainability. + +3. **Documentation Update:** + - Update the project documentation to include details about the new features and how they integrate with the existing library. + - Provide usage examples and guidelines for the new functionalities to assist users in adopting them effectively. + +## Deployment Information + +The successful workflow status indicates that the deployment process was executed without errors. However, given the test failures, it is advisable to hold off on deploying the new version to production until the issues are resolved. + +## Future Planning + +1. **Issue Resolution:** + - Prioritize resolving the test failures to ensure the stability and reliability of the library. + - Investigate the root causes of the test failures and implement necessary fixes. + +2. **Feature Expansion:** + - Plan for future updates that build upon the new functionalities, ensuring they align with the overall project goals and user needs. + +3. **Community Engagement:** + - Engage with the user community to gather feedback on the new features and identify any additional requirements or improvements. + +## Conclusion + +The autodE project has made significant strides with the addition of new files, potentially enhancing its functionality. However, the test failures highlight the need for further refinement and testing. By addressing these issues and enhancing documentation and community engagement, the project can continue to evolve and meet the needs of its users effectively. \ No newline at end of file diff --git a/autodE/mcp_output/mcp_plugin/__init__.py b/autodE/mcp_output/mcp_plugin/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/autodE/mcp_output/mcp_plugin/adapter.py b/autodE/mcp_output/mcp_plugin/adapter.py new file mode 100644 index 0000000000000000000000000000000000000000..320be5c0485a08b405ba56c4f36cea7bdcc2fd0f --- /dev/null +++ b/autodE/mcp_output/mcp_plugin/adapter.py @@ -0,0 +1,248 @@ +import os +import sys + +# Path settings +source_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "source") +sys.path.insert(0, source_path) + +# Import statements +try: + from autode.atoms import Atom, Atoms + from autode.reactions.reaction import Reaction + from autode.calculations.calculation import Calculation + from autode.transition_states.transition_state import TransitionState + from autode.wrappers.ORCA import ORCA + from autode.wrappers.G09 import G09 + from autode.wrappers.G16 import G16 + from autode.wrappers.XTB import XTB + from autode.wrappers.MOPAC import MOPAC + from autode.wrappers.NWChem import NWChem + from autode.wrappers.QChem import QChem +except ImportError as e: + print(f"Import failed: {e}. Ensure all dependencies are installed and the source path is correct.") + +# Adapter class definition +class Adapter: + """ + Adapter class for MCP plugin, providing access to core functionalities + of the autodE package. + """ + + def __init__(self): + self.mode = "import" + + # -------------------- Atom Module -------------------- + + def create_atom(self, element, x, y, z): + """ + Create an Atom instance. + + Parameters: + - element (str): Chemical symbol of the element. + - x (float): X-coordinate of the atom. + - y (float): Y-coordinate of the atom. + - z (float): Z-coordinate of the atom. + + Returns: + - dict: Status and Atom instance or error message. + """ + try: + atom = Atom(element, x, y, z) + return {"status": "success", "atom": atom} + except Exception as e: + return {"status": "error", "message": f"Failed to create Atom: {e}"} + + def create_atoms(self, atom_list): + """ + Create an Atoms instance. + + Parameters: + - atom_list (list): List of Atom instances. + + Returns: + - dict: Status and Atoms instance or error message. + """ + try: + atoms = Atoms(atom_list) + return {"status": "success", "atoms": atoms} + except Exception as e: + return {"status": "error", "message": f"Failed to create Atoms: {e}"} + + # -------------------- Reaction Module -------------------- + + def create_reaction(self, reactants, products, name): + """ + Create a Reaction instance. + + Parameters: + - reactants (list): List of reactant molecules. + - products (list): List of product molecules. + - name (str): Name of the reaction. + + Returns: + - dict: Status and Reaction instance or error message. + """ + try: + reaction = Reaction(reactants, products, name=name) + return {"status": "success", "reaction": reaction} + except Exception as e: + return {"status": "error", "message": f"Failed to create Reaction: {e}"} + + # -------------------- Calculation Module -------------------- + + def run_calculation(self, method, molecule): + """ + Run a quantum chemical calculation. + + Parameters: + - method (str): Calculation method (e.g., 'ORCA', 'G09'). + - molecule (Molecule): Molecule instance to calculate. + + Returns: + - dict: Status and Calculation result or error message. + """ + try: + calculation = Calculation(method=method, molecule=molecule) + calculation.run() + return {"status": "success", "calculation": calculation} + except Exception as e: + return {"status": "error", "message": f"Failed to run Calculation: {e}"} + + # -------------------- Transition State Module -------------------- + + def create_transition_state(self, reaction): + """ + Create a TransitionState instance. + + Parameters: + - reaction (Reaction): Reaction instance. + + Returns: + - dict: Status and TransitionState instance or error message. + """ + try: + ts = TransitionState(reaction) + return {"status": "success", "transition_state": ts} + except Exception as e: + return {"status": "error", "message": f"Failed to create TransitionState: {e}"} + + # -------------------- Wrapper Modules -------------------- + + def use_orca(self, molecule): + """ + Use ORCA wrapper for calculations. + + Parameters: + - molecule (Molecule): Molecule instance. + + Returns: + - dict: Status and ORCA result or error message. + """ + try: + orca = ORCA(molecule) + orca.run() + return {"status": "success", "orca": orca} + except Exception as e: + return {"status": "error", "message": f"Failed to use ORCA: {e}"} + + def use_g09(self, molecule): + """ + Use G09 wrapper for calculations. + + Parameters: + - molecule (Molecule): Molecule instance. + + Returns: + - dict: Status and G09 result or error message. + """ + try: + g09 = G09(molecule) + g09.run() + return {"status": "success", "g09": g09} + except Exception as e: + return {"status": "error", "message": f"Failed to use G09: {e}"} + + def use_g16(self, molecule): + """ + Use G16 wrapper for calculations. + + Parameters: + - molecule (Molecule): Molecule instance. + + Returns: + - dict: Status and G16 result or error message. + """ + try: + g16 = G16(molecule) + g16.run() + return {"status": "success", "g16": g16} + except Exception as e: + return {"status": "error", "message": f"Failed to use G16: {e}"} + + def use_xtb(self, molecule): + """ + Use XTB wrapper for calculations. + + Parameters: + - molecule (Molecule): Molecule instance. + + Returns: + - dict: Status and XTB result or error message. + """ + try: + xtb = XTB(molecule) + xtb.run() + return {"status": "success", "xtb": xtb} + except Exception as e: + return {"status": "error", "message": f"Failed to use XTB: {e}"} + + def use_mopac(self, molecule): + """ + Use MOPAC wrapper for calculations. + + Parameters: + - molecule (Molecule): Molecule instance. + + Returns: + - dict: Status and MOPAC result or error message. + """ + try: + mopac = MOPAC(molecule) + mopac.run() + return {"status": "success", "mopac": mopac} + except Exception as e: + return {"status": "error", "message": f"Failed to use MOPAC: {e}"} + + def use_nwchem(self, molecule): + """ + Use NWChem wrapper for calculations. + + Parameters: + - molecule (Molecule): Molecule instance. + + Returns: + - dict: Status and NWChem result or error message. + """ + try: + nwchem = NWChem(molecule) + nwchem.run() + return {"status": "success", "nwchem": nwchem} + except Exception as e: + return {"status": "error", "message": f"Failed to use NWChem: {e}"} + + def use_qchem(self, molecule): + """ + Use QChem wrapper for calculations. + + Parameters: + - molecule (Molecule): Molecule instance. + + Returns: + - dict: Status and QChem result or error message. + """ + try: + qchem = QChem(molecule) + qchem.run() + return {"status": "success", "qchem": qchem} + except Exception as e: + return {"status": "error", "message": f"Failed to use QChem: {e}"} \ No newline at end of file diff --git a/autodE/mcp_output/mcp_plugin/main.py b/autodE/mcp_output/mcp_plugin/main.py new file mode 100644 index 0000000000000000000000000000000000000000..fca6ec384e22f703b287550e94cc00baaaa4c4a7 --- /dev/null +++ b/autodE/mcp_output/mcp_plugin/main.py @@ -0,0 +1,13 @@ +""" +MCP Service Auto-Wrapper - Auto-generated +""" +from mcp_service import create_app + +def main(): + """Main entry point""" + app = create_app() + return app + +if __name__ == "__main__": + app = main() + app.run() \ No newline at end of file diff --git a/autodE/mcp_output/mcp_plugin/mcp_service.py b/autodE/mcp_output/mcp_plugin/mcp_service.py new file mode 100644 index 0000000000000000000000000000000000000000..1f4732b2b75e82252da18a1083f21d2b5daf2e1e --- /dev/null +++ b/autodE/mcp_output/mcp_plugin/mcp_service.py @@ -0,0 +1,69 @@ +import os +import sys + +# Add the local source directory to sys.path +source_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "source") +if source_path not in sys.path: + sys.path.insert(0, source_path) + +from fastmcp import FastMCP +from autode.atoms import Atom, Atoms +from autode.calculations.calculation import Calculation +from autode.reactions.reaction import Reaction + +# Create the FastMCP service application +mcp = FastMCP("autode_service") + +@mcp.tool(name="atom_properties", description="Get properties of an atom") +def atom_properties(element: str) -> dict: + """ + Get properties of an atom given its element symbol. + + :param element: The chemical symbol of the element (e.g., 'H', 'C', 'O') + :return: A dictionary with success, result, or error fields + """ + try: + atom = Atom(element) + result = { + "mass": atom.mass, + "atomic_number": atom.atomic_number + } + return {"success": True, "result": result} + except Exception as e: + return {"success": False, "error": str(e)} + +@mcp.tool(name="calculate_reaction", description="Perform a quantum chemical calculation for a reaction") +def calculate_reaction(reactant_smiles: str, product_smiles: str) -> dict: + """ + Perform a quantum chemical calculation for a given reaction. + + :param reactant_smiles: SMILES string of the reactant + :param product_smiles: SMILES string of the product + :return: A dictionary with success, result, or error fields + """ + try: + reactant = Reaction.Reactant(smiles=reactant_smiles) + product = Reaction.Product(smiles=product_smiles) + reaction = Reaction(reactant, product) + calculation = Calculation(reaction) + calculation.run() + result = { + "energy": calculation.energy, + "status": calculation.status + } + return {"success": True, "result": result} + except Exception as e: + return {"success": False, "error": str(e)} + +def create_app() -> FastMCP: + """ + Create and return the FastMCP application instance. + + :return: FastMCP instance + """ + return mcp + +# Ensure the module can be run as a script +if __name__ == "__main__": + app = create_app() + app.run() \ No newline at end of file diff --git a/autodE/mcp_output/requirements.txt b/autodE/mcp_output/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..44f0244cbc2cda9509615d6e676ba0867f24d110 --- /dev/null +++ b/autodE/mcp_output/requirements.txt @@ -0,0 +1,13 @@ +fastmcp +fastapi +uvicorn[standard] +pydantic>=2.0.0 +rdkit +numpy +networkx +matplotlib +pillow>=9.5.0 +cython +scipy +loky +ase diff --git a/autodE/mcp_output/start_mcp.py b/autodE/mcp_output/start_mcp.py new file mode 100644 index 0000000000000000000000000000000000000000..fc7fcbd9646ad53f089fc94af8129043a703325a --- /dev/null +++ b/autodE/mcp_output/start_mcp.py @@ -0,0 +1,30 @@ + +""" +MCP Service Startup Entry +""" +import sys +import os + +project_root = os.path.dirname(os.path.abspath(__file__)) +mcp_plugin_dir = os.path.join(project_root, "mcp_plugin") +if mcp_plugin_dir not in sys.path: + sys.path.insert(0, mcp_plugin_dir) + +from mcp_service import create_app + +def main(): + """Start FastMCP service""" + app = create_app() + # Use environment variable to configure port, default 8000 + port = int(os.environ.get("MCP_PORT", "8000")) + + # Choose transport mode based on environment variable + transport = os.environ.get("MCP_TRANSPORT", "stdio") + if transport == "http": + app.run(transport="http", host="0.0.0.0", port=port) + else: + # Default to STDIO mode + app.run() + +if __name__ == "__main__": + main() diff --git a/autodE/mcp_output/workflow_summary.json b/autodE/mcp_output/workflow_summary.json new file mode 100644 index 0000000000000000000000000000000000000000..014843e422fbcc79d0eceab4236e61fd788dd7ca --- /dev/null +++ b/autodE/mcp_output/workflow_summary.json @@ -0,0 +1,216 @@ +{ + "repository": { + "name": "autodE", + "url": "https://github.com/duartegroup/autodE", + "local_path": "/export/zxcpu1/shiweijie/code/ghh/Code2MCP/workspace/autodE", + "description": "Python library", + "features": "Basic functionality", + "tech_stack": "Python", + "stars": 0, + "forks": 0, + "language": "Python", + "last_updated": "", + "complexity": "complex", + "intrusiveness_risk": "medium" + }, + "execution": { + "start_time": 1770182237.7316194, + "end_time": 1770182330.7397785, + "duration": 93.00815939903259, + "status": "success", + "workflow_status": "success", + "nodes_executed": [ + "download", + "analysis", + "env", + "generate", + "run", + "review", + "finalize" + ], + "total_files_processed": 23, + "environment_type": "unknown", + "llm_calls": 0, + "deepwiki_calls": 0 + }, + "tests": { + "original_project": { + "passed": false, + "details": {}, + "test_coverage": "100%", + "execution_time": 0, + "test_files": [] + }, + "mcp_plugin": { + "passed": true, + "details": {}, + "service_health": "healthy", + "startup_time": 0, + "transport_mode": "stdio", + "fastmcp_version": "unknown", + "mcp_version": "unknown" + } + }, + "analysis": { + "structure": { + "packages": [ + "source.autode", + "source.autode.bracket", + "source.autode.calculations", + "source.autode.conformers", + "source.autode.ext", + "source.autode.log", + "source.autode.neb", + "source.autode.opt", + "source.autode.path", + "source.autode.pes", + "source.autode.reactions", + "source.autode.smiles", + "source.autode.solvent", + "source.autode.species", + "source.autode.thermochemistry", + "source.autode.transition_states", + "source.autode.wrappers", + "source.tests", + "source.tests.test_bracket", + "source.tests.test_opt", + "source.tests.test_pes", + "source.tests.test_ts", + "source.tests.test_wrappers" + ] + }, + "dependencies": { + "has_environment_yml": false, + "has_requirements_txt": true, + "pyproject": true, + "setup_cfg": false, + "setup_py": true + }, + "entry_points": { + "imports": [], + "cli": [], + "modules": [] + }, + "risk_assessment": { + "import_feasibility": 0.8, + "intrusiveness_risk": "medium", + "complexity": "complex" + }, + "deepwiki_analysis": { + "repo_url": "https://github.com/duartegroup/autodE", + "repo_name": "autodE", + "content": "duartegroup/autodE\nCore Architecture\nChemical Species and Atoms\nReactions and Bond Rearrangements\nConfiguration System\nTransition State Analysis\nTransition State Location Methods\nTS Validation and Optimization\nMolecular Graphs and Connectivity\nBracketing Methods\nElectronic Structure Interface\nMethod Wrappers\nCalculations and Executors\nKeywords and Thermochemistry\nGeometry Optimization\nCoordinate Systems\nOptimization Algorithms\nConformer Generation\nConformer Generation Algorithms\nConformer Management\nAdditional Systems\nSMILES Processing\nMolecular Truncation\nExplicit Solvation\nPlotting and Visualization\nUtilities and Development\nCore Utilities\nTesting and CI/CD\nautode/__init__.py\nautode/transition_states/templates.py\ndoc/changelog.rst\ndoc/config.rst\ndoc/index.rst\ndoc/install.rst\ndoc/troubleshooting.rst\nexamples/README.md\nPurpose and Scope\nautodE is a Python module designed for the automated calculation of reaction profiles from SMILES strings of reactants and products. This system automates the complex process of finding transition states, performing conformer searches, and generating complete reaction energy profiles using quantum chemical calculations.\nThis overview provides a high-level architectural understanding of autodE's core systems and their interactions. For detailed information about specific subsystems, seeCore Architecture,Transition State Analysis,Electronic Structure Interface, andGeometry Optimization.\nSources:README.md7-11doc/index.rst13-16autode/__init__.py1-71\nCore Workflow and Concepts\nautodE follows a double-ended search approach, starting from reactant and product structures to automatically locate transition states and generate reaction profiles. The typical workflow involves:\nInput Processing: Users provide reactants and products as SMILES strings or 3D structures\nBond Rearrangement Analysis: The system identifies which bonds form and break during the reaction\nTransition State Location: Multiple algorithms search for saddle points connecting reactants to products\nProfile Generation: Complete energy profiles are calculated with conformer searching and thermochemistry\nUser InputReactant/Product SMILESBond Rearrangement Analysisautode.reactions.bond_rearrangementTransition State Locationautode.transition_statesReaction Profile Generationautode.reactions.reactionTemplate Matchingautode.transition_states.templatesAdaptive Path Searchautode.pathNEB Calculationsautode.nebConformer Generationautode.conformersThermochemistryautode.thermochemistryFinal ResultsEnergy profiles & structures\nUser InputReactant/Product SMILES\nBond Rearrangement Analysisautode.reactions.bond_rearrangement\nTransition State Locationautode.transition_states\nReaction Profile Generationautode.reactions.reaction\nTemplate Matchingautode.transition_states.templates\nAdaptive Path Searchautode.path\nNEB Calculationsautode.neb\nConformer Generationautode.conformers\nThermochemistryautode.thermochemistry\nFinal ResultsEnergy profiles & structures\nSources:README.md41-50doc/changelog.rst756-784autode/reactions/reaction.py\nHigh-Level System Architecture\nThe autodE architecture consists of several interconnected layers that handle different aspects of the reaction profile calculation workflow:\nExternal ProgramsElectronic Structure InterfaceOptimization FrameworkTransition State EngineReaction Analysis EngineCore Chemical RepresentationUser InterfaceCommand Line InterfacePython APIautode.Reactionautode.MoleculeConfiguration Systemautode.config.ConfigChemical Speciesautode.species.Speciesautode.species.molecule.MoleculeAtomic Dataautode.atoms.Atomautode.atoms.AtomsCoordinate Systemsautode.opt.coordinatesReaction Objectsautode.reactions.reaction.ReactionBond Rearrangementsautode.reactions.bond_rearrangementMolecular Graphsautode.mol_graphsTS Locationautode.transition_statesTS Templatesautode.transition_states.templatesBracketing Methodsautode.bracketGeometry Optimizersautode.opt.optimisersNEB Methodsautode.nebPath Optimizationautode.pathCalculation Managerautode.calculations.CalculationMethod Wrappersautode.wrappersKeyword Managementautode.wrappers.keywordsORCAautode.wrappers.ORCAGaussianautode.wrappers.G09/G16XTBautode.wrappers.XTBMOPACautode.wrappers.MOPAC\nExternal Programs\nElectronic Structure Interface\nOptimization Framework\nTransition State Engine\nReaction Analysis Engine\nCore Chemical Representation\nUser Interface\nCommand Line Interface\nPython APIautode.Reactionautode.Molecule\nConfiguration Systemautode.config.Config\nChemical Speciesautode.species.Speciesautode.species.molecule.Molecule\nAtomic Dataautode.atoms.Atomautode.atoms.Atoms\nCoordinate Systemsautode.opt.coordinates\nReaction Objectsautode.reactions.reaction.Reaction\nBond Rearrangementsautode.reactions.bond_rearrangement\nMolecular Graphsautode.mol_graphs\nTS Locationautode.transition_states\nTS Templatesautode.transition_states.templates\nBracketing Methodsautode.bracket\nGeometry Optimizersautode.opt.optimisers\nNEB Methodsautode.neb\nPath Optimizationautode.path\nCalculation Managerautode.calculations.Calculation\nMethod Wrappersautode.wrappers\nKeyword Managementautode.wrappers.keywords\nORCAautode.wrappers.ORCA\nGaussianautode.wrappers.G09/G16\nXTBautode.wrappers.XTB\nMOPACautode.wrappers.MOPAC\nSources:autode/__init__.py44-71setup.py37-57doc/changelog.rst overall system diagrams\nKey Components\nChemical Species and Data Structures\nThe foundation of autodE rests on robust chemical data structures that represent atoms, molecules, and their properties:\nautode.atoms.Atom\nautode.species.molecule.Molecule\nautode.species.molecule.Reactant\nautode.species.molecule.Product\nautode.species.complex.NCIComplex\nSources:autode/__init__.py14-16autode/species/autode/atoms.py\nReaction Processing\nThe reaction analysis system identifies chemical changes and guides transition state searches:\nautode.reactions.reaction.Reactionautode.reactions.bond_rearrangement.BondRearrangementautode.mol_graphs.MolecularGraphautode.transition_states.ts_guess.TSguessautode.transition_states.transition_state.TransitionState\nautode.reactions.reaction.Reaction\nautode.reactions.bond_rearrangement.BondRearrangement\nautode.mol_graphs.MolecularGraph\nautode.transition_states.ts_guess.TSguess\nautode.transition_states.transition_state.TransitionState\nSources:autode/reactions/autode/mol_graphs/autode/transition_states/\nElectronic Structure Integration\nautodE provides a unified interface to multiple quantum chemistry packages through method wrappers:\nautode.wrappers.ORCA\nautode.wrappers.G09\nautode.wrappers.G16\nautode.wrappers.XTB\nautode.wrappers.MOPAC\nautode.wrappers.NWChem\nautode.wrappers.QChem\nSources:README.md15-24autode/wrappers/doc/install.rst10-22\nConfiguration and Extensibility\nThe system is highly configurable through theautode.config.Configclass, which manages:\nautode.config.Config\nElectronic structure method selection and keywords\nOptimization parameters and convergence criteria\nParallel execution settings\nTemplate libraries for transition state finding\nLogging and output control\nautode.config.ConfigMethod ConfigurationConfig.ORCA, Config.XTB, etc.Keyword ManagementConfig.keywordsCore Settingsn_cores, max_core, etc.Optimization Keywordsautode.wrappers.keywords.OptKeywordsSingle Point Keywordsautode.wrappers.keywords.SinglePointKeywordsHessian Keywordsautode.wrappers.keywords.HessianKeywords\nautode.config.Config\nMethod ConfigurationConfig.ORCA, Config.XTB, etc.\nKeyword ManagementConfig.keywords\nCore Settingsn_cores, max_core, etc.\nOptimization Keywordsautode.wrappers.keywords.OptKeywords\nSingle Point Keywordsautode.wrappers.keywords.SinglePointKeywords\nHessian Keywordsautode.wrappers.keywords.HessianKeywords\nSources:autode/config.pydoc/config.rst1-217autode/wrappers/keywords/\nUsage Patterns\nThe primary usage pattern involves creatingReactionobjects from reactants and products, then invoking the automated workflow:\nimportautodeasade# Define reactants and productsreactant = ade.Reactant(smiles='CCHii[H]')product = ade.Product(smiles='CHiiC')# Create reaction and calculate profilereaction = ade.Reaction(reactant, product, name='1-2_shift')reaction.calculate_reaction_profile()\nimportautodeasade# Define reactants and productsreactant = ade.Reactant(smiles='CCHii[H]')product = ade.Product(smiles='CHiiC')# Create reaction and calculate profilereaction = ade.Reaction(reactant, product, name='1-2_shift')reaction.calculate_reaction_profile()\nimportautodeasade# Define reactants and productsreactant = ade.Reactant(smiles='CCHii[H]')product = ade.Product(smiles='CHiiC')# Create reaction and calculate profilereaction = ade.Reaction(reactant, product, name='1-2_shift')reaction.calculate_reaction_profile()\nThis high-level interface abstracts the complexity of transition state location, conformer generation, and thermochemical analysis while providing full control over the underlying quantum chemical calculations.\nSources:README.md41-50examples/README.md1-8doc/quickstart.rst examples\nRefresh this wiki\nOn this page\nPurpose and Scope\nCore Workflow and Concepts\nHigh-Level System Architecture\nKey Components\nChemical Species and Data Structures\nReaction Processing\nElectronic Structure Integration\nConfiguration and Extensibility\nUsage Patterns", + "model": "gpt-4o-2024-08-06", + "source": "selenium", + "success": true + }, + "code_complexity": { + "cyclomatic_complexity": "medium", + "cognitive_complexity": "medium", + "maintainability_index": 75 + }, + "security_analysis": { + "vulnerabilities_found": 0, + "security_score": 85, + "recommendations": [] + } + }, + "plugin_generation": { + "files_created": [ + "mcp_output/start_mcp.py", + "mcp_output/mcp_plugin/__init__.py", + "mcp_output/mcp_plugin/mcp_service.py", + "mcp_output/mcp_plugin/adapter.py", + "mcp_output/mcp_plugin/main.py", + "mcp_output/requirements.txt", + "mcp_output/README_MCP.md" + ], + "main_entry": "start_mcp.py", + "requirements": [ + "fastmcp>=0.1.0", + "pydantic>=2.0.0" + ], + "readme_path": "/export/zxcpu1/shiweijie/code/ghh/Code2MCP/workspace/autodE/mcp_output/README_MCP.md", + "adapter_mode": "import", + "total_lines_of_code": 0, + "generated_files_size": 0, + "tool_endpoints": 0, + "supported_features": [ + "Basic functionality" + ], + "generated_tools": [ + "Basic tools", + "Health check tools", + "Version info tools" + ] + }, + "code_review": {}, + "errors": [], + "warnings": [], + "recommendations": [ + "Improve test coverage by adding more unit tests for critical modules", + "streamline the import process to reduce complexity", + "enhance documentation for better clarity on core functionalities", + "optimize large files for better performance", + "implement continuous integration to automate testing and deployment", + "refactor code to improve readability and maintainability", + "ensure all dependencies are up-to-date and compatible", + "enhance error handling to improve robustness", + "consider adding more examples and tutorials for user guidance", + "improve logging for better traceability and debugging." + ], + "performance_metrics": { + "memory_usage_mb": 0, + "cpu_usage_percent": 0, + "response_time_ms": 0, + "throughput_requests_per_second": 0 + }, + "deployment_info": { + "supported_platforms": [ + "Linux", + "Windows", + "macOS" + ], + "python_versions": [ + "3.8", + "3.9", + "3.10", + "3.11", + "3.12" + ], + "deployment_methods": [ + "Docker", + "pip", + "conda" + ], + "monitoring_support": true, + "logging_configuration": "structured" + }, + "execution_analysis": { + "success_factors": [ + "Comprehensive workflow execution with all nodes completed successfully", + "Efficient processing of 23 files within a short duration" + ], + "failure_reasons": [], + "overall_assessment": "excellent", + "node_performance": { + "download_time": "Efficient, no delays reported", + "analysis_time": "Completed successfully, indicating effective analysis processes", + "generation_time": "Swift generation of MCP service components", + "test_time": "Original project tests failed, but MCP plugin tests passed" + }, + "resource_usage": { + "memory_efficiency": "Not explicitly measured, but no memory issues reported", + "cpu_efficiency": "Not explicitly measured, but no CPU issues reported", + "disk_usage": "Efficient, with minimal generated file size" + } + }, + "technical_quality": { + "code_quality_score": 75, + "architecture_score": 80, + "performance_score": 85, + "maintainability_score": 75, + "security_score": 85, + "scalability_score": 80 + } +} \ No newline at end of file diff --git a/autodE/source/.pre-commit-config.yaml b/autodE/source/.pre-commit-config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..cf98951f7d3ac03d620655b3ac282c56b4d7c6b5 --- /dev/null +++ b/autodE/source/.pre-commit-config.yaml @@ -0,0 +1,19 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: trailing-whitespace + - id: mixed-line-ending + + - repo: https://github.com/psf/black + rev: 23.9.1 + hooks: + - id: black + language_version: python3 + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.5.1 + hooks: + - id: mypy + exclude: "tests/|doc/|examples/" + args: [--ignore-missing-imports] diff --git a/autodE/source/CONTRIBUTING.md b/autodE/source/CONTRIBUTING.md new file mode 100644 index 0000000000000000000000000000000000000000..844c6250bff7ed29830070b99538c90891d30ce6 --- /dev/null +++ b/autodE/source/CONTRIBUTING.md @@ -0,0 +1,5 @@ +# Contributing to autodE + +Contributions in any form are very much welcome. To make managing these +easier, we kindly ask that you follow the guidelines outlined +[here](https://duartegroup.github.io/autodE/dev/contributing.html). diff --git a/autodE/source/LICENSE.md b/autodE/source/LICENSE.md new file mode 100644 index 0000000000000000000000000000000000000000..edd05eeb8e21a88558152a048023b479f735608c --- /dev/null +++ b/autodE/source/LICENSE.md @@ -0,0 +1,22 @@ + +The MIT License (MIT) + +Copyright (c) 2018 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/autodE/source/README.md b/autodE/source/README.md new file mode 100644 index 0000000000000000000000000000000000000000..7b2b0e17b38ab5feab8837c9b2a8c9a78caae337 --- /dev/null +++ b/autodE/source/README.md @@ -0,0 +1,100 @@ +[![Build Status](https://github.com/duartegroup/autodE/actions/workflows/pytest.yml/badge.svg)](https://github.com/duartegroup/autodE/actions) [![codecov](https://codecov.io/gh/duartegroup/autodE/branch/master/graph/badge.svg)](https://codecov.io/gh/duartegroup/autodE/branch/master) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![GitHub CodeQL](https://github.com/duartegroup/autodE/actions/workflows/codeql.yml/badge.svg)](https://github.com/duartegroup/autodE/actions/workflows/codeql.yml) [![Conda Recipe](https://img.shields.io/badge/recipe-autode-green.svg)](https://anaconda.org/conda-forge/autode) [![Conda Downloads](https://img.shields.io/conda/dn/conda-forge/autode.svg)](https://anaconda.org/conda-forge/autode) + +![alt text](autode/common/llogo.png) +*** +## Introduction + +**autodE** is a Python module initially designed for the automated calculation of reaction profiles from SMILES strings of +reactant(s) and product(s). Current features include: transition state location, conformer searching, atom mapping, +Python wrappers for a range of electronic structure theory codes, SMILES parsing, association complex generation, and + reaction profile generation. + + +### Dependencies +* [Python](https://www.python.org/) > v. 3.7 +* One of: + * [ORCA](https://sites.google.com/site/orcainputlibrary/home/) > v. 4.0 + * [Gaussian09](https://gaussian.com/glossary/g09/) + * [Gaussian16](https://gaussian.com/gaussian16/) + * [NWChem](http://www.nwchem-sw.org/index.php/Main_Page) > 6.5 + * [QChem](https://www.q-chem.com/) > 5.4 +* One of: + * [XTB](https://www.chemie.uni-bonn.de/pctc/mulliken-center/software/xtb/xtb/) > v. 6.1 + * [MOPAC](http://openmopac.net/) + +The Python dependencies are listed in requirements.txt are best satisfied using a conda install (Miniconda or Anaconda). + +## Installation + +To install **autodE** with [conda](https://anaconda.org/conda-forge/autode): +``` +conda install autode -c conda-forge +``` +see the [installation guide](https://duartegroup.github.io/autodE/install.html) for installing from source. + +## Usage + +Reaction profiles in **autodE** are generated by initialising _Reactant_ and _Product_ objects, +generating a _Reaction_ from those and invoking _calculate_reaction_profile()_. +For example, to calculate the profile for a 1,2 hydrogen shift in a propyl radical: + +```python +import autode as ade +ade.Config.n_cores = 8 + +r = ade.Reactant(name='reactant', smiles='CC[C]([H])[H]') +p = ade.Product(name='product', smiles='C[C]([H])C') + +reaction = ade.Reaction(r, p, name='1-2_shift') +reaction.calculate_reaction_profile() # creates 1-2_shift/ and saves profile +``` + +See [examples/](https://github.com/duartegroup/autodE/tree/master/examples) for +more examples and [duartegroup.github.io/autodE/](https://duartegroup.github.io/autodE/) for +additional documentation. + + +## Development + +There is a [slack workspace](https://autodeworkspace.slack.com) for development and discussion - please +[email](mailto:autodE-gh@outlook.com?subject=autodE%20slack) to be added. Pull requests are +very welcome but must pass all the unit tests prior to being merged. Please write code and tests! +See the [todo list](https://github.com/duartegroup/autodE/projects/1) for features on the horizon. +Bugs and feature requests should be raised on the [issue page](https://github.com/duartegroup/autodE/issues). + +> **_NOTE:_** We'd love more contributors to this project! + + +## Citation + +If **autodE** is used in a publication please consider citing the [paper](https://doi.org/10.1002/anie.202011941): + +``` +@article{autodE, + doi = {10.1002/anie.202011941}, + url = {https://doi.org/10.1002/anie.202011941}, + year = {2021}, + publisher = {Wiley}, + volume = {60}, + number = {8}, + pages = {4266--4274}, + author = {Tom A. Young and Joseph J. Silcock and Alistair J. Sterling and Fernanda Duarte}, + title = {{autodE}: Automated Calculation of Reaction Energy Profiles -- Application to Organic and Organometallic Reactions}, + journal = {Angewandte Chemie International Edition} +} +``` + + +## Contributors + +- Tom Young ([@t-young31](https://github.com/t-young31)) +- Joseph Silcock ([@josephsilcock](https://github.com/josephsilcock)) +- Kjell Jorner ([@kjelljorner](https://github.com/kjelljorner)) +- Thibault Lestang ([@tlestang](https://github.com/tlestang)) +- Domen Pregeljc ([@dpregeljc](https://github.com/dpregeljc)) +- Jonathon Vandezande ([@jevandezande](https://github.com/jevandezande)) +- Shoubhik Maiti ([@shoubhikraj](https://github.com/shoubhikraj)) +- Daniel Hollas ([@danielhollas](https://github.com/danielhollas)) +- Nils Heunemann ([@nilsheunemann](https://github.com/NilsHeunemann)) +- Sijie Fu ([@sijiefu](https://github.com/SijieFu)) +- Javier Alfonso ([@javialra97](https://github.com/javialra97)) diff --git a/autodE/source/__init__.py b/autodE/source/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..467c3abff8475ab18ff3cc05dfbb895a0a4e141d --- /dev/null +++ b/autodE/source/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +""" +autodE Project Package Initialization File +""" diff --git a/autodE/source/autode/__init__.py b/autodE/source/autode/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0f2505752bffe75d34b9b2c9fdc52644898907a9 --- /dev/null +++ b/autodE/source/autode/__init__.py @@ -0,0 +1,71 @@ +import importlib.metadata + +from autode import methods +from autode import geom +from autode import pes +from autode import utils +from autode import neb +from autode import mol_graphs +from autode import hessians +from autode.neb import NEB, CINEB +from autode.reactions.reaction import Reaction +from autode.reactions.multistep import MultiStepReaction +from autode.transition_states.transition_state import TransitionState +from autode.atoms import Atom +from autode.species.molecule import Reactant, Product, Molecule, Species +from autode.species.complex import NCIComplex +from autode.config import Config +from autode.calculations import Calculation +from autode.wrappers.keywords import ( + KeywordsSet, + OptKeywords, + HessianKeywords, + SinglePointKeywords, + Keywords, + GradientKeywords, +) +from autode.utils import temporary_config + +""" +Bumping the version number requires following the release procedure: + +- Run tests/benchmark.py with both organic and organometallic sets + +- Release on conda-forge + - Fork https://github.com/conda-forge/autode-feedstock + - Make a local branch + - Modify recipe/meta.yaml with the new version number, sha256 + - Push commit and open PR on the conda-forge feedstock + - Merge when tests pass +""" + +__version__ = importlib.metadata.version("autode") + +__all__ = [ + "KeywordsSet", + "Keywords", + "OptKeywords", + "HessianKeywords", + "SinglePointKeywords", + "GradientKeywords", + "Reaction", + "MultiStepReaction", + "Atom", + "Species", + "Reactant", + "Product", + "Molecule", + "TransitionState", + "NCIComplex", + "Config", + "Calculation", + "NEB", + "CINEB", + "pes", + "neb", + "geom", + "methods", + "mol_graphs", + "utils", + "hessians", +] diff --git a/autodE/source/autode/atoms.py b/autodE/source/autode/atoms.py new file mode 100644 index 0000000000000000000000000000000000000000..675f7a9574225f02e604f59df3e3920e0a023b78 --- /dev/null +++ b/autodE/source/autode/atoms.py @@ -0,0 +1,1865 @@ +import numpy as np +from copy import deepcopy +from typing import Union, Optional, List, Sequence, Any +from autode.log import logger +from autode.geom import get_rot_mat_euler +from autode.values import ( + Distance, + Angle, + Mass, + Coordinate, + Coordinates, + MomentOfInertia, +) + + +class Atom: + def __init__( + self, + atomic_symbol: str, + x: Any = 0.0, + y: Any = 0.0, + z: Any = 0.0, + atom_class: Optional[int] = None, + partial_charge: Optional[float] = None, + ): + """ + Atom class. Centered at the origin by default. Can be initialised from + positional or keyword arguments: + + .. code-block:: Python + + >>> import autode as ade + >>> ade.Atom('H') + Atom(H, 0.0000, 0.0000, 0.0000) + >>> + >>> ade.Atom('H', x=1.0, y=1.0, z=1.0) + Atom(H, 1.0000, 1.0000, 1.0000) + >>> + >>> ade.Atom('H', 1.0, 1.0, 1.0) + Atom(H, 1.0000, 1.0000, 1.0000) + + ----------------------------------------------------------------------- + Arguments: + atomic_symbol: Symbol of an element e.g. 'C' for carbon + + x: x coordinate in 3D space (Å) + + y: y coordinate in 3D space (Å) + + z: z coordinate in 3D space (Å) + + atom_class: Fictitious additional labels to distinguish otherwise + identical atoms. Useful in finding bond isomorphisms + over identity reactions + + partial_charge: Partial atomic charge in units of e, determined by + the atomic envrionment. Not an observable property. + """ + assert atomic_symbol in elements + + self.label = atomic_symbol + self._coord = Coordinate(float(x), float(y), float(z)) + self.atom_class = atom_class + self.partial_charge = ( + None if partial_charge is None else float(partial_charge) + ) + + def __repr__(self): + """ + Representation of this atom + + ----------------------------------------------------------------------- + Returns: + (str): Representation + """ + x, y, z = self.coord + return f"Atom({self.label}, {x:.4f}, {y:.4f}, {z:.4f})" + + def __str__(self): + return self.__repr__() + + def __eq__(self, other: Any): + """Equality of another atom to this one""" + are_equal = ( + isinstance(other, Atom) + and other.label == self.label + and other.atom_class == self.atom_class + and isinstance(other.partial_charge, type(self.partial_charge)) + and ( + (other.partial_charge is None and self.partial_charge is None) + or np.isclose(other.partial_charge, self.partial_charge) + ) + and np.allclose(other._coord, self._coord) + ) + return are_equal + + @property + def atomic_number(self) -> int: + """ + Atomic numbers are the position in the elements (indexed from zero), + plus one. Example: + + .. code-block:: Python + + >>> import autode as ade + >>> atom = ade.Atom('C') + >>> atom.atomic_number + 6 + + ----------------------------------------------------------------------- + Returns: + (int): Atomic number + """ + return elements.index(self.label) + 1 + + @property + def atomic_symbol(self) -> str: + """ + A more interpretable alias for Atom.label. Should be present in the + elements. Example: + + .. code-block:: Python + + >>> import autode as ade + >>> atom = ade.Atom('Zn') + >>> atom.atomic_symbol + 'Zn' + + ----------------------------------------------------------------------- + Returns: + (str): Atomic symbol + """ + return self.label + + @property + def coord(self) -> Coordinate: + """ + Position of this atom in space. Coordinate has attributes x, y, z + for the Cartesian displacements. Example: + + .. code-block:: Python + + >>> import autode as ade + >>> atom = ade.Atom('H') + >>> atom.coord + Coordinate([0. 0. 0.] Å) + + To initialise at a different position away from the origin + + .. code-block:: Python + + >>> ade.Atom('H', x=1.0).coord + Coordinate([1. 0. 0.] Å) + >>> ade.Atom('H', x=1.0).coord.x + 1.0 + + Coordinates are instances of autode.values.ValueArray, so can + be converted from the default angstrom units to e.g. Bohr + + .. code-block:: Python + + >>> ade.Atom('H', x=1.0, y=-1.0).coord.to('a0') + Coordinate([1.889 -1.889 0. ] bohr) + + ----------------------------------------------------------------------- + Returns: + (autode.values.Coordinate): Coordinate + """ + return self._coord + + @coord.setter + def coord(self, *args): + """ + Coordinate setter + + ----------------------------------------------------------------------- + Arguments: + *args (float | list(float) | np.ndarray(float)): + + Raises: + (ValueError): If the arguments cannot be coerced into a (3,) shape + """ + self._coord = Coordinate(*args) + + @property + def is_metal(self) -> bool: + """ + Is this atom a metal? Defines metals to be up to and including: + Ga, Sn, Bi. Example: + + .. code-block:: Python + + >>> import autode as ade + >>> ade.Atom('C').is_metal + False + >>> ade.Atom('Zn').is_metal + True + + ----------------------------------------------------------------------- + Returns: + (bool): + """ + return self.label in metals + + @property + def group(self) -> int: + """ + Group of the periodic table is this atom in. 0 if not found. Example: + + .. code-block:: Python + + >>> import autode as ade + >>> ade.Atom('C').group + 14 + + ----------------------------------------------------------------------- + Returns: + (int): Group + """ + + for group_idx in range(1, 18): + if self.label in PeriodicTable.group(group_idx): + return group_idx + + return 0 + + @property + def period(self) -> int: + """ + Period of the periodic table is this atom in. 0 if not found. Example: + + .. code-block:: Python + + >>> import autode as ade + >>> ade.Atom('C').period + 2 + + ----------------------------------------------------------------------- + Returns: + (int): Period + """ + + for period_idx in range(1, 7): + if self.label in PeriodicTable.period(period_idx): + return period_idx + + return 0 + + @property + def tm_row(self) -> Optional[int]: + """ + Row of transition metals that this element is in. Returns None if + this atom is not a metal. Example: + + .. code-block:: Python + + >>> import autode as ade + >>> ade.Atom('Zn').tm_row + 1 + + ----------------------------------------------------------------------- + Returns: + (int | None): Transition metal row + """ + for row in [1, 2, 3]: + if self.label in PeriodicTable.transition_metals(row): + return row + + return None + + @property + def weight(self) -> Mass: + """ + Atomic weight. Example: + + .. code-block:: Python + + >>> import autode as ade + >>> ade.Atom('C').weight + Mass(12.0107 amu) + >>> + >>> ade.Atom('C').weight == ade.Atom('C').mass + True + + ----------------------------------------------------------------------- + Returns: + (autode.values.Mass): Weight + """ + + try: + return Mass(atomic_weights[self.label]) + + except KeyError: + logger.warning( + f"Could not find a valid weight for {self.label}. " + f"Guessing at 70" + ) + return Mass(70) + + @property + def mass(self) -> Mass: + """Alias of weight. Returns Atom.weight an so can be converted + to different units. For example, to convert the mass to electron masses: + + .. code-block:: Python + + >>> import autode as ade + >>> ade.Atom('H').mass.to('me') + Mass(1837.36222 m_e) + + ----------------------------------------------------------------------- + Returns: + (autode.values.Mass): Mass + """ + return self.weight + + @property + def maximal_valance(self) -> int: + """ + The maximum/maximal valance that this atom supports in any charge + state (most commonly). i.e. for H the maximal_valance=1. Useful for + generating molecular graphs + + ----------------------------------------------------------------------- + Returns: + (int): Maximal valance + """ + + if self.is_metal: + return 6 + + if self.label in _max_valances: + return _max_valances[self.label] + + logger.warning( + f"Could not find a valid valance for {self}. " f"Guessing at 6" + ) + return 6 + + @property + def vdw_radius(self) -> Distance: + """ + Van der Waals radius for this atom. Example: + + .. code-block:: Python + + >>> import autode as ade + >>> ade.Atom('H').vdw_radius + Distance(1.1 Å) + + ----------------------------------------------------------------------- + Returns: + (autode.values.Distance): Van der Waals radius + """ + + if self.label in vdw_radii: + radius = vdw_radii[self.label] + else: + logger.error( + f"Couldn't find the VdV radii for {self}. " + f"Guessing at 2.3 Å" + ) + radius = 2.3 + + return Distance(radius, "Å") + + @property + def covalent_radius(self) -> Distance: + """ + Covalent radius for this atom. Example: + + .. code-block:: Python + + >>> import autode as ade + >>> ade.Atom('H').covalent_radius + Distance(0.31 Å) + + ----------------------------------------------------------------------- + Returns: + (autode.values.Distance): Van der Waals radius + """ + radius = Distance( + _covalent_radii_pm[self.atomic_number - 1], units="pm" + ) + return radius.to("Å") + + def is_pi(self, valency: int) -> bool: + """ + Determine if this atom is a 'π-atom' i.e. is unsaturated. Only + approximate! Example: + + .. code-block:: Python + + >>> import autode as ade + >>> ade.Atom('C').is_pi(valency=3) + True + >>> ade.Atom('H').is_pi(valency=1) + False + + ----------------------------------------------------------------------- + Arguments: + valency (int): + + Returns: + (bool): + """ + + if self.label in non_pi_elements: + return False + + if self.label not in pi_valencies: + logger.warning( + f"{self.label} not found in π valency dictionary - " + f"assuming not a π-atom" + ) + return False + + if valency in pi_valencies[self.label]: + return True + + return False + + def translate(self, *args, **kwargs) -> None: + """ + Translate this atom by a vector in place. Arguments should be + coercible into a coordinate (i.e. length 3). Example: + + .. code-block:: Python + + >>> import autode as ade + >>> atom = ade.Atom('H') + >>> atom.translate(1.0, 0.0, 0.0) + >>> atom.coord + Coordinate([1. 0. 0.] Å) + + Atoms can also be translated using numpy arrays: + + .. code-block:: Python + + >>> import autode as ade + >>> import numpy as np + >>> + >>> atom = ade.Atom('H') + >>> atom.translate(np.ones(3)) + >>> atom.coord + Coordinate([1. 1. 1.] Å) + >>> + >>> atom.translate(vec=-atom.coord) + >>> atom.coord + Coordinate([0. 0. 0.] Å) + + ----------------------------------------------------------------------- + Arguments: + *args (float | np.ndarray | list(float)): + + Keyword Arguments: + vec (np.ndarray): Shape = (3,) + """ + if "vec" in kwargs: + # Assume the vec is cast-able to a numpy array which can be added + self.coord += np.asarray(kwargs["vec"]) + + elif len(kwargs) > 0: + raise ValueError( + f"Expecting only a vec keyword argument. " f"Had {kwargs}" + ) + + else: + self.coord += Coordinate(*args) + + return None + + def rotate( + self, + axis: Union[np.ndarray, Sequence], + theta: Union[Angle, float], + origin: Union[np.ndarray, Sequence, None] = None, + ) -> None: + """ + Rotate this atom theta radians around an axis given an origin. By + default the rotation is applied around the origin with the angle + in radians (unless an autode.values.Angle). Rotation is applied in + place. To rotate a H atom around the z-axis: + + .. code-block:: Python + + >>> import autode as ade + >>> atom = ade.Atom('H', x=1.0) + >>> atom.rotate(axis=[0.0, 0.0, 1.0], theta=3.14) + >>> atom.coord + Coordinate([-1. 0. 0.] Å) + + With an origin: + + .. code-block:: Python + + >>> import autode as ade + >>> atom = ade.Atom('H') + >>> atom.rotate(axis=[0.0, 0.0, 1.0], theta=3.14, origin=[1.0, 0.0, 0.0]) + >>> atom.coord + Coordinate([2. 0. 0.] Å) + + And with an angle not in radians: + + .. code-block:: Python + + >>> import autode as ade + >>> from autode.values import Angle + >>> + >>> atom = ade.Atom('H', x=1.0) + >>> atom.rotate(axis=[0.0, 0.0, 1.0], theta=Angle(180, units='deg')) + >>> atom.coord + Coordinate([-1. 0. 0.] Å) + + ----------------------------------------------------------------------- + Arguments: + axis: Axis to rotate in. shape = (3,) + + theta: Angle to rotate by + + origin: Rotate about this origin. shape = (3,) if no origin is + specified then the atom is rotated without translation. + """ + # If specified, shift so that the origin is at (0, 0, 0) + if origin is not None: + self.translate(vec=-np.asarray(origin)) + + # apply the rotation + rot_matrix = get_rot_mat_euler(axis=axis, theta=theta) + self.coord = np.matmul(rot_matrix, self.coord) + + # and shift back, if required + if origin is not None: + self.translate(vec=np.asarray(origin)) + + return None + + def copy(self) -> "Atom": + return deepcopy(self) + + # --- Method aliases --- + coordinate = coord + + +class DummyAtom(Atom): + def __init__(self, x, y, z): + """ + Dummy atom + + ----------------------------------------------------------------------- + Arguments: + x (float): x coordinate in 3D space (Å) + y (float): y + z (float): z + """ + # Superclass constructor called with a valid element... + super().__init__("H", x, y, z) + + # then re-assigned + self.label = "D" + + @property + def atomic_number(self): + """The atomic number is defined as 0 for a dummy atom""" + return 0 + + @property + def weight(self) -> Mass: + """Dummy atoms do not have any weight/mass""" + return Mass(0.0) + + @property + def mass(self) -> Mass: + """Dummy atoms do not have any weight/mass""" + return Mass(0.0) + + @property + def vdw_radius(self) -> Distance: + """Dummy atoms have no radius""" + return Distance(0.0, units="Å") + + @property + def covalent_radius(self) -> Distance: + """Dummy atoms have no radius""" + return Distance(0.0, units="Å") + + +class Atoms(list): + def __repr__(self): + """Representation""" + return f"Atoms(n_atoms={len(self)}, {super().__repr__()})" + + def __add__(self, other): + """Add another set of Atoms to this one. Can add None""" + if other is None: + return self + + return super().__add__(other) + + def __radd__(self, other): + """Add another set of Atoms to this one. Can add None""" + return self.__add__(other) + + def copy(self) -> "Atoms": + """ + Copy these atoms, deeply + + ----------------------------------------------------------------------- + Returns: + (autode.atoms.Atoms): + """ + return deepcopy(self) + + def remove_dummy(self) -> None: + """Remove all the dummy atoms from this list of atoms""" + + for i, atom in enumerate(self): + if isinstance(atom, DummyAtom): + del self[i] + return + + @property + def coordinates(self) -> Coordinates: + return Coordinates(np.array([a.coord for a in self])) + + @coordinates.setter + def coordinates(self, value: np.ndarray): + """Set the coordinates from a numpy array + + ----------------------------------------------------------------------- + Arguments: + value (np.ndarray): Shape = (n_atoms, 3) or (3*n_atoms) as a + row major vector + """ + + if value.ndim == 1: + assert value.shape == (3 * len(self),) + value = value.reshape((-1, 3)) + + elif value.ndim == 2: + assert value.shape == (len(self), 3) + + else: + raise AssertionError( + "Cannot set coordinates from a array with" + f"shape: {value.shape}. Must be 1 or 2 " + f"dimensional" + ) + + for i, atom in enumerate(self): + atom.coord = Coordinate(*value[i]) + + @property + def com(self) -> Coordinate: + r""" + Centre of mass of these coordinates + + .. math:: + \text{COM} = \frac{1}{M} \sum_i m_i R_i + + where M is the total mass, m_i the mass of atom i and R_i it's + coordinate + + ----------------------------------------------------------------------- + Returns: + (autode.values.Coordinate): COM + """ + if len(self) == 0: + raise ValueError("Undefined centre of mass with no atoms") + + com = Coordinate(0.0, 0.0, 0.0) + + for atom in self: + com += atom.mass * atom.coord + + return Coordinate(com / sum(atom.mass for atom in self)) + + @property + def moi(self) -> MomentOfInertia: + """ + Moment of inertia matrix (I):: + + (I_00 I_01 I_02) + I = (I_10 I_11 I_12) + (I_20 I_21 I_22) + + Returns: + (autode.values.MomentOfInertia): + """ + moi = MomentOfInertia(np.zeros(shape=(3, 3)), units="amu Å^2") + + for atom in self: + mass, (x, y, z) = atom.mass, atom.coord + + moi[0, 0] += mass * (y**2 + z**2) + moi[0, 1] -= mass * (x * y) + moi[0, 2] -= mass * (x * z) + + moi[1, 0] -= mass * (y * x) + moi[1, 1] += mass * (x**2 + z**2) + moi[1, 2] -= mass * (y * z) + + moi[2, 0] -= mass * (z * x) + moi[2, 1] -= mass * (z * y) + moi[2, 2] += mass * (x**2 + y**2) + + return moi + + @property + def contain_metals(self) -> bool: + """ + Do these atoms contain at least a single metal atom? + + ----------------------------------------------------------------------- + Returns: + (bool): + """ + return any(atom.label in metals for atom in self) + + def idxs_are_present(self, *args: int) -> bool: + """Are all these indexes present in this set of atoms""" + return set(args).issubset(set(range(len(self)))) + + def eqm_bond_distance(self, i: int, j: int) -> Distance: + """ + Equilibrium distance between two atoms. If known then use the + experimental dimer distance, otherwise estimate if from the + covalent radii of the two atoms. Example + + Example: + + .. code-block:: Python + + >>> import autode as ade + >>> mol = ade.Molecule(atoms=[ade.Atom('H'), ade.Atom('H')]) + >>> mol.distance(0, 1) + Distance(0.0 Å) + >>> mol.eqm_bond_distance(0, 1) + Distance(0.741 Å) + + ----------------------------------------------------------------------- + Returns: + (autode.values.Distance): Equlirbium distance + """ + if not self.idxs_are_present(i, j): + raise ValueError( + f"Cannot calculate the equilibrium distance " + f"between {i}-{j}. At least one atom not present" + ) + + if i == j: + return Distance(0.0, units="Å") + + symbols = f"{self[i].atomic_symbol}{self[j].atomic_symbol}" + + if symbols in _bond_lengths: + return Distance(_bond_lengths[symbols], units="Å") + + # TODO: Something more accurate here + return self[i].covalent_radius + self[j].covalent_radius + + def distance(self, i: int, j: int) -> Distance: + """ + Distance between two atoms (Å), indexed from 0. + + .. code-block:: Python + + >>> import autode as ade + >>> mol = ade.Molecule(atoms=[ade.Atom('H'), ade.Atom('H', x=1.0)]) + >>> mol.distance(0, 1) + Distance(1.0 Å) + + ----------------------------------------------------------------------- + Arguments: + i (int): Atom index of the first atom + j (int): Atom index of the second atom + + Returns: + (autode.values.Distance): Distance + + Raises: + (ValueError): + """ + if not self.idxs_are_present(i, j): + raise ValueError( + f"Cannot calculate the distance between {i}-{j}. " + f"At least one atom not present" + ) + + return Distance(np.linalg.norm(self[i].coord - self[j].coord)) + + def vector(self, i: int, j: int) -> np.ndarray: + """ + Vector from atom i to atom j + + ----------------------------------------------------------------------- + Arguments: + i (int): + j (int): + + Returns: + (np.ndarray): + + Raises: + (IndexError): If i or j are not present + """ + return np.asarray(self[j].coord - self[i].coord) + + def nvector(self, i: int, j: int) -> np.ndarray: + """ + Normalised vector from atom i to atom j + + ----------------------------------------------------------------------- + Arguments: + i (int): + j (int): + + Returns: + (np.ndarray): + + Raises: + (IndexError): If i or j are not present + """ + vec = self.vector(i, j) + return vec / np.linalg.norm(vec) + + def are_linear(self, angle_tol: Angle = Angle(1, "º")) -> bool: + """ + Are these set of atoms colinear? + + ----------------------------------------------------------------------- + Arguments: + angle_tol (autode.values.Angle): Tolerance on the angle + + Returns: + (bool): Whether the atoms are linear + """ + if len(self) < 2: # Must have at least 2 atoms colinear + return False + + if len(self) == 2: # Two atoms must be linear + return True + + tol = np.abs(1.0 - np.cos(angle_tol.to("rad"))) + + vec0 = self.nvector(0, 1) # Normalised first vector + + for atom in self[2:]: + vec = atom.coord - self[0].coord + cos_theta = np.dot(vec, vec0) / np.linalg.norm(vec) + + # Both e.g. <179° and >1° should satisfy this condition for + # angle_tol = 1° + if np.abs(np.abs(cos_theta) - 1) > tol: + return False + + return True + + def are_planar(self, distance_tol: Distance = Distance(1e-3, "Å")) -> bool: + """ + Do all the atoms in this set lie in a single plane? + + ----------------------------------------------------------------------- + Arguments: + distance_tol (autode.values.Distance): + + Returns: + (bool): + """ + if len(self) < 4: # 3 points must lie in a plane + return True + + arr = self.coordinates.to("Å") + + if isinstance(distance_tol, Distance): + distance_tol_float = float(distance_tol.to("Å")) + + else: + logger.warning("Assuming a distance tolerance in units of Å") + distance_tol_float = float(distance_tol) + + # Calculate a normal vector to the first two atomic vectors from atom 0 + x0 = arr[0, :] + normal_vec = np.cross(arr[1, :] - x0, arr[2, :] - x0) + + for i in range(3, len(self)): + # Calculate the 0->i atomic vector, which must not have any + # component in the direction in the normal if the atoms are planar + if np.dot(normal_vec, arr[i, :] - x0) > distance_tol_float: + return False + + return True + + +class AtomCollection: + def __init__(self, atoms: Union[List[Atom], Atoms, None] = None): + """ + Collection of atoms, used as a base class for a species, complex + or transition state. + + ----------------------------------------------------------------------- + Arguments: + atoms (autode.atoms.Atoms | list(autode.atoms.Atom) | None): + """ + self._atoms = Atoms(atoms) if atoms is not None else None + + @property + def n_atoms(self) -> int: + """Number of atoms in this collection""" + return 0 if self.atoms is None else len(self.atoms) + + @property + def coordinates(self) -> Optional[Coordinates]: + """Numpy array of coordinates""" + if self.atoms is None: + return None + + return self.atoms.coordinates + + @coordinates.setter + def coordinates(self, value: np.ndarray): + """Set the coordinates from a numpy array + + ----------------------------------------------------------------------- + Arguments: + value (np.ndarray): Shape = (n_atoms, 3) or (3*n_atoms) as a + row major vector + """ + if self._atoms is None: + raise ValueError( + "Must have atoms set to be able to set the " + "coordinates of them" + ) + + self._atoms.coordinates = value + + @property + def atoms(self) -> Optional[Atoms]: + """Constituent atoms of this collection""" + return self._atoms + + @atoms.setter + def atoms(self, value: Union[List[Atom], Atoms, None]): + """Set the constituent atoms of this collection""" + self._atoms = Atoms(value) if value is not None else None + + @property + def com(self) -> Optional[Coordinate]: + """Centre of mass of this atom collection + + ----------------------------------------------------------------------- + Returns: + (autode.values.Coordinate): COM + + Raises: + (ValueError): If there are no atoms + """ + return None if self.atoms is None else self.atoms.com + + @property + def moi(self) -> Optional[MomentOfInertia]: + """ + Moment of inertia matrix (I) + + ----------------------------------------------------------------------- + Returns: + (autode.values.MomentOfInertia): + """ + return None if self.atoms is None else self.atoms.moi + + @property + def weight(self) -> Mass: + """ + Molecular weight + + ----------------------------------------------------------------------- + Returns: + (autode.values.Mass): + """ + if self.n_atoms == 0: + return Mass(0.0) + + return sum(atom.mass for atom in self.atoms) # type: ignore + + def distance(self, i: int, j: int) -> Distance: + assert self.atoms is not None, "Must have atoms" + return self.atoms.distance(i, j) + + def eqm_bond_distance(self, i: int, j: int) -> Distance: + assert self.atoms is not None, "Must have atoms" + return self.atoms.eqm_bond_distance(i, j) + + def angle(self, i: int, j: int, k: int) -> Angle: + r""" + Angle between three atoms i-j-k, where the atoms are indexed from + zero:: + + E_i --- E_j + \ + θ E_k + + + Example: + + .. code-block:: Python + + >>> from autode import Atom, Molecule + >>> h2o = Molecule(atoms=[Atom('H', x=-1), Atom('O'), Atom('H', x=1)]) + >>> h2o.angle(0, 1, 2).to('deg') + Angle(180.0 °) + + + ----------------------------------------------------------------------- + Arguments: + i (int): Atom index of the left hand side in the angle + j (int): --- middle + k (int): --- right + + Returns: + (autode.values.Angle): Angle + + Raises: + (ValueError): If any of the atom indexes are not present + """ + assert self.atoms is not None, "Must have atoms" + + if not self.atoms.idxs_are_present(i, j, k): + raise ValueError( + f"Cannot calculate the angle between {i}-{j}-{k}." + f" At least one atom not present" + ) + + vec1 = self.atoms[i].coord - self.atoms[j].coord + vec2 = self.atoms[k].coord - self.atoms[j].coord + + norms = np.linalg.norm(vec1) * np.linalg.norm(vec2) + + if np.isclose(norms, 0.0): + raise ValueError( + f"Cannot calculate the angle {i}-{j}-{k} - at " + f"least one zero vector" + ) + + # Cos(theta) must lie within [-1, 1] + cos_value = np.clip(np.dot(vec1, vec2) / norms, a_min=-1, a_max=1) + + return Angle(np.arccos(cos_value)) + + def dihedral(self, w: int, x: int, y: int, z: int) -> Angle: + r""" + Dihedral angle between four atoms (x, y, z, w), where the atoms are + indexed from zero:: + + E_w --- E_x + \ φ + \ + E_y ---- E_z + + Example: + + .. code-block:: Python + + >>> from autode import Atom, Molecule + >>> h2s2 = Molecule(atoms=[Atom('S', 0.1527, 0.9668, -0.9288), + ... Atom('S', 2.0024, 0.0443, -0.4227), + ... Atom('H', -0.5802, 0.0234, -0.1850), + ... Atom('H', 2.1446, 0.8424, 0.7276)]) + >>> h2s2.dihedral(2, 0, 1, 3).to('deg') + Angle(-90.0 °) + + ----------------------------------------------------------------------- + Arguments: + w (int): Atom index of the first atom in the dihedral + x (int): -- second -- + y (int): -- third -- + z (int): -- fourth -- + + Returns: + (autode.values.Angle): Dihedral angle + + Raises: + (ValueError): If any of the atom indexes are not present in the + molecule + """ + assert self.atoms is not None, "Must have atoms" + + if not self.atoms.idxs_are_present(w, x, y, z): + raise ValueError( + f"Cannot calculate the dihedral angle involving " + f"atoms {z}-{w}-{x}-{y}. At least one atom not " + f"present" + ) + + vec_xw = self.atoms[w].coord - self.atoms[x].coord + vec_yz = self.atoms[z].coord - self.atoms[y].coord + vec_xy = self.atoms[y].coord - self.atoms[x].coord + + vec1, vec2 = np.cross(vec_xw, vec_xy), np.cross(-vec_xy, vec_yz) + + # Normalise and ensure no zero vectors, for which the dihedral is not + # defined + for vec in (vec1, vec2, vec_xy): + norm = np.linalg.norm(vec) + + if np.isclose(norm, 0.0): + raise ValueError( + f"Cannot calculate the dihedral angle " + f"{z}-{w}-{x}-{y} - one zero vector" + ) + vec /= norm + + """ + Dihedral angles are defined as from the IUPAC gold book: "the torsion + angle between groups A and D is then considered to be positive if + the bond A-B is rotated in a clockwise direction through less than + 180 degrees" + """ + value = -np.arctan2( + np.dot(np.cross(vec1, vec_xy), vec2), np.dot(vec1, vec2) + ) + + return Angle(value) + + # --- Method aliases --- + centre_of_mass = com + moment_of_inertia = moi + mass = weight + + +elements = [ + "H", + "He", + "Li", + "Be", + "B", + "C", + "N", + "O", + "F", + "Ne", + "Na", + "Mg", + "Al", + "Si", + "P", + "S", + "Cl", + "Ar", + "K", + "Ca", + "Sc", + "Ti", + "V", + "Cr", + "Mn", + "Fe", + "Co", + "Ni", + "Cu", + "Zn", + "Ga", + "Ge", + "As", + "Se", + "Br", + "Kr", + "Rb", + "Sr", + "Y", + "Zr", + "Nb", + "Mo", + "Tc", + "Ru", + "Rh", + "Pd", + "Ag", + "Cd", + "In", + "Sn", + "Sb", + "Te", + "I", + "Xe", + "Cs", + "Ba", + "La", + "Ce", + "Pr", + "Nd", + "Pm", + "Sm", + "Eu", + "Gd", + "Tb", + "Dy", + "Ho", + "Er", + "Tm", + "Yb", + "Lu", + "Hf", + "Ta", + "W", + "Re", + "Os", + "Ir", + "Pt", + "Au", + "Hg", + "Tl", + "Pb", + "Bi", + "Po", + "At", + "Rn", + "Fr", + "Ra", + "Ac", + "Th", + "Pa", + "U", + "Np", + "Pu", + "Am", + "Cm", + "Bk", + "Cf", + "Es", + "Fm", + "Md", + "No", + "Lr", + "Rf", + "Db", + "Sg", + "Bh", + "Hs", + "Mt", + "Ds", + "Rg", + "Cn", + "Nh", + "Fl", + "Mc", + "Lv", + "Ts", + "Og", +] + + +class PeriodicTable: + # fmt: off + table = np.array( + [['H', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', 'He'], + ['Li', 'Be', '', '', '', '', '', '', '', '', '', '', 'B', 'C', 'N', 'O', 'F', 'Ne'], + ['Na', 'Mg', '', '', '', '', '', '', '', '', '', '', 'Al', 'Si', 'P', 'S', 'Cl', 'Ar'], + ['K', 'Ca', 'Sc', 'Ti', 'V', 'Cr', 'Mn', 'Fe', 'Co', 'Ni', 'Cu', 'Zn', 'Ga', 'Ge', 'As', 'Se', 'Br', 'Kr'], + ['Rb', 'Sr', 'Y', 'Zr', 'Nb', 'Mo', 'Tc', 'Ru', 'Rh', 'Pd', 'Ag', 'Cd', 'In', 'Sn', 'Sb', 'Te', 'I', 'Xe'], + ['Cs', 'Ba', '', 'Hf', 'Ta', 'W', 'Re', 'Os', 'Ir', 'Pt', 'Au', 'Hg', 'Tl', 'Pb', 'Bi', 'Po', 'At', 'Rn'], + ['Fr', 'Ra', '', 'Rf', 'Db', 'Sg', 'Bh', 'Hs', 'Mt', 'Ds', 'Rg', 'Cn', 'Nh', 'Fl', 'Mc', 'Lv', 'Ts', 'Og']], + dtype=str + ) + # fmt: on + + @classmethod + def period(cls, n: int): + """ + Period of the periodic table, with 1 being the first period + + ----------------------------------------------------------------------- + Arguments: + n (int): + + Returns: + (np.ndarray(str)): + + Raises: + (ValueError): If n is not valid period index + """ + if n < 1 or n > 7: + raise ValueError("Not a valid period. Must be 1-7") + + # Exclude the empty strings of non-present elements + return np.array([elem for elem in cls.table[n - 1, :] if elem != ""]) + + @classmethod + def group(cls, n: int): + """ + Group of the periodic table, with 1 being the first period + + ----------------------------------------------------------------------- + Arguments: + n (int): + + Returns: + (np.ndarray(str)): + + Raises: + (ValueError): If n is not valid group index + """ + if n < 1 or n > 18: + raise ValueError("Not a valid group. Must be 1-18") + + # Exclude the empty strings of non-present elements + return np.array([elem for elem in cls.table[:, n - 1] if elem != ""]) + + @classmethod + def element(cls, period: int, group: int): + """ + Element given it's index in the periodic table, excluding + lanthanides and actinides. + + ----------------------------------------------------------------------- + Arguments: + period (int): + + group (int): + + Returns: + (str): Atomic symbol of the element + + Raises: + (IndexError): If such an element does not exist + """ + try: + elem = cls.table[ + period - 1, group - 1 + ] # Convert from 1 -> 0 indexing + assert elem != "" + + except (IndexError, AssertionError): + raise IndexError("Index of the element not found") + + return elem + + @classmethod + def transition_metals(cls, row: int): + """ + Collection of transition metals (TMs) of a defined row. e.g. + + row = 1 -> [Sc, Ti .. Zn] + + ----------------------------------------------------------------------- + Arguments: + row (int): Colloquial name for TMs period + + Returns: + (np.ndarray(str)): + + Raises: + (ValueError): If the row is not valid + """ + if row < 1 or row > 3: + raise ValueError("Not a valid row of TMs. Must be 1-3") + + tms = [elem for elem in cls.period(row + 3) if elem in metals] + return np.array(tms, dtype=str) + + lanthanoids = lanthanides = np.array( + [ + "La", + "Ce", + "Pr", + "Nd", + "Pm", + "Sm", + "Eu", + "Gd", + "Tb", + "Dy", + "Ho", + "Er", + "Tm", + "Yb", + "Lu", + ], + dtype=str, + ) + actinoids = actinides = np.array( + [ + "Ac", + "Th", + "Pa", + "U", + "Np", + "Pu", + "Am", + "Cm", + "Bk", + "Cf", + "Es", + "Fm", + "Md", + "No", + "Lr", + ], + dtype=str, + ) + + +# A set of reasonable valances for anionic/neutral/cationic atoms +valid_valances = { + "H": [0, 1], + "B": [3, 4], + "C": [2, 3, 4], + "N": [2, 3, 4], + "O": [1, 2, 3], + "F": [0, 1], + "Si": [2, 3, 4], + "P": [2, 3, 4, 5, 6], + "S": [2, 3, 4, 5, 6], + "Cl": [0, 1, 2, 3, 4], + "Br": [0, 1, 2, 3, 4], + "I": [0, 1, 2, 3, 4, 5, 6], + "Rh": [0, 1, 2, 3, 4, 5, 6], +} + +# Atomic weights in amu from: +# IUPAC-CIAWW's Atomic weights of the elements: Review 2000 +atomic_weights = { + "H": 1.00794, + "He": 4.002602, + "Li": 6.941, + "Be": 9.012182, + "B": 10.811, + "C": 12.0107, + "N": 14.0067, + "O": 15.9994, + "F": 18.9984032, + "Ne": 2.01797, + "Na": 22.989770, + "Mg": 24.3050, + "Al": 26.981538, + "Si": 28.0855, + "P": 30.973761, + "S": 32.065, + "Cl": 35.453, + "Ar": 39.948, + "K": 39.0983, + "Ca": 40.078, + "Sc": 44.955910, + "Ti": 47.867, + "V": 50.9415, + "Cr": 51.9961, + "Mn": 54.938049, + "Fe": 55.845, + "Co": 58.933200, + "Ni": 58.6934, + "Cu": 63.546, + "Zn": 65.409, + "Ga": 69.723, + "Ge": 72.64, + "As": 74.92160, + "Se": 78.96, + "Br": 79.904, + "Kr": 83.798, + "Rb": 85.4678, + "Sr": 87.62, + "Y": 88.90585, + "Zr": 91.224, + "Nb": 92.90638, + "Mo": 95.94, + "Ru": 101.07, + "Rh": 102.90550, + "Pd": 106.42, + "Ag": 107.8682, + "Cd": 112.411, + "In": 114.818, + "Sn": 118.710, + "Sb": 121.760, + "Te": 127.60, + "I": 126.90447, + "Xe": 131.293, + "Cs": 132.90545, + "Ba": 137.327, + "La": 138.9055, + "Ce": 140.116, + "Pr": 140.90765, + "Nd": 144.24, + "Sm": 150.36, + "Eu": 151.964, + "Gd": 157.25, + "Tb": 158.92534, + "Dy": 162.500, + "Ho": 164.93032, + "Er": 167.259, + "Tm": 168.93421, + "Yb": 173.04, + "Lu": 174.967, + "Hf": 178.49, + "Ta": 180.9479, + "W": 183.84, + "Re": 186.207, + "Os": 190.23, + "Ir": 192.217, + "Pt": 195.078, + "Au": 196.96655, + "Hg": 200.59, + "Tl": 204.3833, + "Pb": 207.2, + "Bi": 208.98038, + "Th": 232.0381, + "Pa": 231.03588, + "U": 238.02891, + # Remainder from https://ciaaw.org/atomic-masses.htm + "Np": 237.0, + "Pu": 244.0, + "Am": 243.0, + "Cm": 247.0, + "Bk": 247.0, + "Cf": 251.0, + "Es": 252.0, + "Fm": 257.0, + "Md": 258.0, + "No": 259.0, + "Lr": 262.0, + "Rf": 267.0, + "Db": 268.0, + "Sg": 271.0, + "Bh": 274.0, + "Hs": 269.0, + "Mt": 276.0, + "Ds": 281.0, + "Rg": 281.0, + "Cn": 285.0, + "Nh": 286.0, + "Fl": 289.0, + "Mc": 288.0, + "Lv": 293.0, + "Ts": 294.0, + "Og": 294.0, +} + +# van der Walls radii from https://books.google.no/books?id=bNDMBQAAQBAJ +vdw_radii = { + "H": 1.1, + "He": 1.4, + "Li": 1.82, + "Be": 1.53, + "B": 1.92, + "C": 1.7, + "N": 1.55, + "O": 1.52, + "F": 1.47, + "Ne": 1.54, + "Na": 2.27, + "Mg": 1.73, + "Al": 1.84, + "Si": 2.1, + "P": 1.8, + "S": 1.8, + "Cl": 1.75, + "Ar": 1.88, + "K": 2.75, + "Ca": 2.31, + "Sc": 2.15, + "Ti": 2.11, + "V": 2.07, + "Cr": 2.06, + "Mn": 2.05, + "Fe": 2.04, + "Co": 2.0, + "Ni": 1.97, + "Cu": 1.96, + "Zn": 2.01, + "Ga": 1.87, + "Ge": 2.11, + "As": 1.85, + "Se": 1.9, + "Br": 1.85, + "Kr": 2.02, + "Rb": 3.03, + "Sr": 2.49, + "Y": 2.32, + "Zr": 2.23, + "Nb": 2.18, + "Mo": 2.17, + "Tc": 2.16, + "Ru": 2.13, + "Rh": 2.1, + "Pd": 2.1, + "Ag": 2.11, + "Cd": 2.18, + "In": 1.93, + "Sn": 2.17, + "Sb": 2.06, + "Te": 2.06, + "I": 1.98, + "Xe": 2.16, + "Cs": 3.43, + "Ba": 2.68, + "La": 2.43, + "Ce": 2.42, + "Pr": 2.4, + "Nd": 2.39, + "Pm": 2.38, + "Sm": 2.36, + "Eu": 2.35, + "Gd": 2.34, + "Tb": 2.33, + "Dy": 2.31, + "Ho": 2.3, + "Er": 2.29, + "Tm": 2.27, + "Yb": 2.26, + "Lu": 2.24, + "Hf": 2.23, + "Ta": 2.22, + "W": 2.18, + "Re": 2.16, + "Os": 2.16, + "Ir": 2.13, + "Pt": 2.13, + "Au": 2.14, + "Hg": 2.23, + "Tl": 1.96, + "Pb": 2.02, + "Bi": 2.07, + "Po": 1.97, + "At": 2.02, + "Rn": 2.2, + "Fr": 3.48, + "Ra": 2.83, + "Ac": 2.47, + "Th": 2.45, + "Pa": 2.43, + "U": 2.41, + "Np": 2.39, + "Pu": 2.43, + "Am": 2.44, + "Cm": 2.45, + "Bk": 2.44, + "Cf": 2.45, + "Es": 2.45, + "Fm": 2.45, + "Md": 2.46, + "No": 2.46, + "Lr": 2.46, +} + +""" +Although a π-bond may not be well defined, it is useful to have a notion of +a bond about which there is restricted rotation. The below sets are used to +define which atoms may be π-bonded to another +""" +non_pi_elements = ["H", "He"] +pi_valencies = { + "B": [1, 2], + "N": [1, 2], + "O": [1], + "C": [1, 2, 3], + "P": [1, 2, 3, 4], + "S": [1, 3, 4, 5], + "Si": [1, 2, 3], +} + +# Standard definition of metallic elements: https://en.wikipedia.org/wiki/Metal +# (all semi-metals not included) +metals = [ + "Li", + "Be", + "Na", + "Mg", + "Al", + "K", + "Ca", + "Sc", + "Ti", + "V", + "Cr", + "Mn", + "Fe", + "Co", + "Ni", + "Cu", + "Zn", + "Ga", + "Rb", + "Sr", + "Y", + "Zr", + "Nb", + "Mo", + "Tc", + "Ru", + "Rh", + "Pd", + "Ag", + "Cd", + "In", + "Sn", + "Cs", + "Ba", + "La", + "Ce", + "Pr", + "Nd", + "Pm", + "Sm", + "Eu", + "Gd", + "Tb", + "Dy", + "Ho", + "Er", + "Tm", + "Yb", + "Lu", + "Hf", + "Ta", + "W", + "Re", + "Os", + "Ir", + "Pt", + "Au", + "Hg", + "Tl", + "Pb", + "Bi", + "Po", + "Fr", + "Ra", + "Ac", + "Th", + "Pa", + "U", + "Np", + "Pu", + "Am", + "Cm", + "Bk", + "Cf", + "Es", + "Fm", + "Md", + "No", + "Lr", + "Rf", + "Db", + "Sg", + "Bh", + "Hs", + "Mt", + "Ds", + "Rg", + "Cn", + "Nh", + "Fl", + "Mc", + "Lv", +] + +# Covalent radii in picometers from https://en.wikipedia.org/wiki/Covalent_radius +_covalent_radii_pm = [ + 31.0, + 28.0, + 128.0, + 96.0, + 84.0, + 76.0, + 71.0, + 66.0, + 57.0, + 58.0, + 166.0, + 141.0, + 121.0, + 111.0, + 107.0, + 105.0, + 102.0, + 106.0, + 102.0, + 203.0, + 176.0, + 170.0, + 160.0, + 153.0, + 139.0, + 161.0, + 152.0, + 150.0, + 124.0, + 132.0, + 122.0, + 122.0, + 120.0, + 119.0, + 120.0, + 116.0, + 220.0, + 195.0, + 190.0, + 175.0, + 164.0, + 154.0, + 147.0, + 146.0, + 142.0, + 139.0, + 145.0, + 144.0, + 142.0, + 139.0, + 139.0, + 138.0, + 139.0, + 140.0, + 244.0, + 215.0, + 207.0, + 204.0, + 203.0, + 201.0, + 199.0, + 198.0, + 198.0, + 196.0, + 194.0, + 192.0, + 192.0, + 189.0, + 190.0, + 187.0, + 175.0, + 187.0, + 170.0, + 162.0, + 151.0, + 144.0, + 141.0, + 136.0, + 136.0, + 132.0, + 145.0, + 146.0, + 148.0, + 140.0, + 150.0, + 150.0, +] + +# Experimental bond lengths from https://cccbdb.nist.gov/diatomicexpbondx.asp +_bond_lengths = {"HH": 0.741, "FF": 1.412, "ClCl": 1.988, "II": 2.665} + + +_max_valances = { + "H": 1, + "He": 0, + "B": 4, + "C": 4, + "N": 4, + "O": 3, + "F": 1, + "Si": 4, + "P": 6, + "S": 6, + "Cl": 4, + "Br": 4, + "I": 6, + "Xe": 6, + "Al": 4, +} diff --git a/autodE/source/autode/bond_rearrangement.py b/autodE/source/autode/bond_rearrangement.py new file mode 100644 index 0000000000000000000000000000000000000000..63601e38380fe51c03d32f672c6bd4a06a05989c --- /dev/null +++ b/autodE/source/autode/bond_rearrangement.py @@ -0,0 +1,876 @@ +import itertools +import os +from autode.geom import get_neighbour_list +from autode.log import logger +from autode.config import Config +from autode.mol_graphs import ( + get_bond_type_list, + get_fbonds, + is_isomorphic, + find_cycles, +) + + +def get_bond_rearrangs(reactant, product, name, save=True): + """For a reactant and product (mol_complex) find the set of breaking and + forming bonds that will turn reactants into products. This works by + determining the types of bonds that have been made/broken (i.e CH) and + then only considering rearrangements involving those bonds. + + --------------------------------------------------------------------------- + Arguments: + reactant (autode.species.ReactantComplex): + + product (autode.species.ProductComplex): + + name (str): + + Keyword Arguments: + save (bool): Save bond rearrangements to a file for fast reloading + + Returns: + (list(autode.bond_rearrangements.BondRearrangement)): + """ + logger.info(f"Finding the possible forming and breaking bonds for {name}") + + if os.path.exists(f"{name}_bond_rearrangs.txt"): + return get_bond_rearrangs_from_file(f"{name}_bond_rearrangs.txt") + + if is_isomorphic(reactant.graph, product.graph) and product.n_atoms > 3: + logger.error( + "Reactant (complex) is isomorphic to product (complex). " + "Bond rearrangement cannot be determined unless the " + "substrates are limited in size" + ) + return None + + possible_brs = [] + + reac_bond_dict = get_bond_type_list(reactant.graph) + prod_bond_dict = get_bond_type_list(product.graph) + + # list of bonds where this type of bond (e.g C-H) has less bonds in + # products than reactants + all_possible_bbonds = [] + + # list of bonds that can be formed of this bond type. This is only used + # if there is only one type of bbond, so can be overwritten for each new + # type of bbond + bbond_atom_type_fbonds = None + + # list of bonds where this type of bond (e.g C-H) has more bonds in + # products than reactants + all_possible_fbonds = [] + + # list of bonds that can be broken of this bond type. This is only used + # if there is only one type of fbond, so can be overwritten for each new + # type of fbond + fbond_atom_type_bbonds = None + + # list of bonds where this type of bond (e.g C-H) has the same number of + # bonds in products and reactants + possible_bbond_and_fbonds = [] + + for reac_key, reac_bonds in reac_bond_dict.items(): + prod_bonds = prod_bond_dict[reac_key] + possible_fbonds = get_fbonds(reactant.graph, reac_key) + if len(prod_bonds) < len(reac_bonds): + all_possible_bbonds.append(reac_bonds) + bbond_atom_type_fbonds = possible_fbonds + elif len(prod_bonds) > len(reac_bonds): + all_possible_fbonds.append(possible_fbonds) + fbond_atom_type_bbonds = reac_bonds + else: + if len(reac_bonds) != 0: + possible_bbond_and_fbonds.append([reac_bonds, possible_fbonds]) + + # The change in the number of bonds is > 0 as in the reaction + # initialisation reacs/prods are swapped if this is < 0 + delta_n_bonds = ( + reactant.graph.number_of_edges() - product.graph.number_of_edges() + ) + + if delta_n_bonds == 0: + funcs = [get_fbonds_bbonds_1b1f, get_fbonds_bbonds_2b2f] + elif delta_n_bonds == 1: + funcs = [get_fbonds_bbonds_1b, get_fbonds_bbonds_2b1f] + elif delta_n_bonds == 2: + funcs = [get_fbonds_bbonds_2b] + else: + logger.error( + f"Cannot treat a change in bonds " + f"reactant <- product of {delta_n_bonds}" + ) + return None + + for func in funcs: + possible_brs = func( + reactant, + product, + possible_brs, + all_possible_bbonds, + all_possible_fbonds, + possible_bbond_and_fbonds, + bbond_atom_type_fbonds, + fbond_atom_type_bbonds, + ) + + if len(possible_brs) > 0: + logger.info( + f"Found a molecular graph rearrangement to products " + f"with {func.__name__}" + ) + # This function will return with the first bond rearrangement + # that leads to products + + n_bond_rearrangs = len(possible_brs) + if n_bond_rearrangs > 1: + logger.info( + f"Multiple *{n_bond_rearrangs}* possible bond " + f"breaking/makings are possible" + ) + possible_brs = strip_equiv_bond_rearrs(possible_brs, reactant) + prune_small_ring_rearrs(possible_brs, reactant) + + if save: + save_bond_rearrangs_to_file( + possible_brs, filename=f"{name}_BRs.txt" + ) + + logger.info( + f"Found *{len(possible_brs)}* bond " + f"rearrangement(s) that lead to products" + ) + return possible_brs + + return None + + +def save_bond_rearrangs_to_file(brs, filename="bond_rearrangs.txt"): + """ + Save a list of bond rearrangements to a file in plane text + + --------------------------------------------------------------------------- + Arguments: + brs (list(autode.bond_rearrangements.BondRearrangement)): + + filename (str): + """ + logger.info(f"Saving bond rearrangements to {filename}") + + with open(filename, "w") as file: + for bond_rearrang in brs: + print("fbonds", file=file) + for fbond in bond_rearrang.fbonds: + print(*fbond, file=file) + print("bbonds", file=file) + for bbond in bond_rearrang.bbonds: + print(*bbond, file=file) + print("end", file=file) + + return None + + +def get_bond_rearrangs_from_file(filename="bond_rearrangs.txt"): + """ + Extract a list of bond rearrangements from a file + + --------------------------------------------------------------------------- + Keyword Arguments: + filename (str): + + Returns: + (list(autode.bond_rearrangements.BondRearrangement)): + """ + logger.info("Getting bond rearrangements from file") + + if not os.path.exists(filename): + logger.error("No bond rearrangements file") + return None + + bond_rearrangs = [] + + with open(filename, "r") as br_file: + fbonds_block = False + fbonds, bbonds = [], [] + for line in br_file: + if "fbonds" in line: + fbonds_block = True + + if "bbonds" in line: + fbonds_block = False + + if len(line.split()) == 2: + atom_idx0, atom_idx1 = (int(val) for val in line.split()) + + if fbonds_block: + fbonds.append((atom_idx0, atom_idx1)) + if not fbonds_block: + bbonds.append((atom_idx0, atom_idx1)) + + if "end" in line: + bond_rearrangs.append( + BondRearrangement( + forming_bonds=fbonds, breaking_bonds=bbonds + ) + ) + fbonds = [] + bbonds = [] + + return bond_rearrangs + + +def add_bond_rearrangment(bond_rearrangs, reactant, product, fbonds, bbonds): + """ + For a possible bond rearrangement, sees if the products are made, and + adds it to the bond rearrang list if it does + + --------------------------------------------------------------------------- + Arguments: + bond_rearrangs (list(autode.bond_rearrangements.BondRearrangement)): + list of working bond rearrangements + + reactant (autode.species.Complex): Reactant complex + + product (autode.species.Complex): Product complex + + fbonds (list(tuple)): list of bonds to be made + + bbonds (list(tuple)): list of bonds to be broken + + Returns: + (list(autode.bond_rearrangements.BondRearrangement)): + """ + + # Check that the bond rearrangement doesn't exceed standard atom valances + bbond_atoms = [atom for bbond in bbonds for atom in bbond] + for fbond in fbonds: + for idx in fbond: + if ( + reactant.graph.degree(idx) + == reactant.atoms[idx].maximal_valance + and idx not in bbond_atoms + ): + # If we are here then there is at least one atom that will + # exceed it's maximal valance, therefore + # we don't need to run isomorphism + return bond_rearrangs + + rearranged_graph = generate_rearranged_graph( + reactant.graph, fbonds=fbonds, bbonds=bbonds + ) + + if is_isomorphic(rearranged_graph, product.graph): + ordered_fbonds = [] + ordered_bbonds = [] + for fbond in fbonds: + if fbond[0] < fbond[1]: + ordered_fbonds.append((fbond[0], fbond[1])) + else: + ordered_fbonds.append((fbond[1], fbond[0])) + for bbond in bbonds: + if bbond[0] < bbond[1]: + ordered_bbonds.append((bbond[0], bbond[1])) + else: + ordered_bbonds.append((bbond[1], bbond[0])) + + ordered_fbonds.sort() + ordered_bbonds.sort() + bond_rearrangs.append( + BondRearrangement( + forming_bonds=ordered_fbonds, breaking_bonds=ordered_bbonds + ) + ) + + return bond_rearrangs + + +def generate_rearranged_graph(graph, fbonds, bbonds): + """Generate a rearranged graph by breaking bonds (edge) and forming others + (edge) + + --------------------------------------------------------------------------- + Arguments: + graph (nx.Graph): reactant graph + + fbonds (list(tuple)): list of bonds to be made + + bbonds (list(tuple)): list of bonds to be broken + + Returns: + nx.Graph: rearranged graph + """ + + rearranged_graph = graph.copy() + for fbond in fbonds: + rearranged_graph.add_edge(*fbond) + for bbond in bbonds: + rearranged_graph.remove_edge(*bbond) + + return rearranged_graph + + +def get_fbonds_bbonds_1b( + reac, + prod, + possible_brs, + all_possible_bbonds, + all_possible_fbonds, + possible_bbond_and_fbonds, + bbond_atom_type_fbonds, + fbond_atom_type_bbonds, +): + logger.info("Getting possible 1 breaking bond rearrangements") + + for bbond in all_possible_bbonds[0]: + # Break one bond + possible_brs = add_bond_rearrangment( + possible_brs, reac, prod, fbonds=[], bbonds=[bbond] + ) + + return possible_brs + + +def get_fbonds_bbonds_2b( + reac, + prod, + possible_brs, + all_possible_bbonds, + all_possible_fbonds, + possible_bbond_and_fbonds, + bbond_atom_type_fbonds, + fbond_atom_type_bbonds, +): + logger.info("Getting possible 2 breaking bond rearrangements") + + if len(all_possible_bbonds) == 1: + # Break two bonds of the same type + for bbond1, bbond2 in itertools.combinations( + all_possible_bbonds[0], 2 + ): + possible_brs = add_bond_rearrangment( + possible_brs, reac, prod, fbonds=[], bbonds=[bbond1, bbond2] + ) + + elif len(all_possible_bbonds) == 2: + # Break two bonds of different types + for bbond1, bbond2 in itertools.product( + all_possible_bbonds[0], all_possible_bbonds[1] + ): + possible_brs = add_bond_rearrangment( + possible_brs, reac, prod, fbonds=[], bbonds=[bbond1, bbond2] + ) + + return possible_brs + + +def get_fbonds_bbonds_1b1f( + reac, + prod, + possible_brs, + all_possible_bbonds, + all_possible_fbonds, + possible_bbond_and_fbonds, + bbond_atom_type_fbonds, + fbond_atom_type_bbonds, +): + logger.info( + "Getting possible 1 breaking and 1 forming bond " "rearrangements" + ) + + if len(all_possible_bbonds) == 1 and len(all_possible_fbonds) == 1: + # Make and break a bond of different types + for fbond, bbond in itertools.product( + all_possible_fbonds[0], all_possible_bbonds[0] + ): + possible_brs = add_bond_rearrangment( + possible_brs, reac, prod, fbonds=[fbond], bbonds=[bbond] + ) + + elif len(all_possible_bbonds) == 0 and len(all_possible_fbonds) == 0: + # Make and break a bond of the same type + for bbonds, fbonds in possible_bbond_and_fbonds: + for bbond, fbond in itertools.product(bbonds, fbonds): + possible_brs = add_bond_rearrangment( + possible_brs, reac, prod, fbonds=[fbond], bbonds=[bbond] + ) + + return possible_brs + + +def get_fbonds_bbonds_2b1f( + reac, + prod, + possible_brs, + all_possible_bbonds, + all_possible_fbonds, + possible_bbond_and_fbonds, + bbond_atom_type_fbonds, + fbond_atom_type_bbonds, +): + logger.info( + "Getting possible 2 breaking and 1 forming bond rearrangements" + ) + + if len(all_possible_bbonds) == 2 and len(all_possible_fbonds) == 1: + # Make a bond and break two bonds, all of different types + possibles = itertools.product( + all_possible_fbonds[0], + all_possible_bbonds[0], + all_possible_bbonds[1], + ) + + for fbond, bbond1, bbond2 in possibles: + possible_brs = add_bond_rearrangment( + possible_brs, + reac, + prod, + fbonds=[fbond], + bbonds=[bbond1, bbond2], + ) + + elif len(all_possible_bbonds) == 1 and len(all_possible_fbonds) == 1: + # Make a bond of one type, break two bonds of another type + two_same_possibles = itertools.combinations(all_possible_bbonds[0], 2) + possibles = itertools.product( + all_possible_fbonds[0], two_same_possibles + ) + + for fbond, (bbond1, bbond2) in possibles: + possible_brs = add_bond_rearrangment( + possible_brs, + reac, + prod, + fbonds=[fbond], + bbonds=[bbond1, bbond2], + ) + + elif len(all_possible_bbonds) == 1 and len(all_possible_fbonds) == 0: + for bbonds, fbonds in possible_bbond_and_fbonds: + # Make and break a bond of one type, break a bond of a different + # type + possibles = itertools.product( + fbonds, all_possible_bbonds[0], bbonds + ) + + for fbond, bbond1, bbond2 in possibles: + possible_brs = add_bond_rearrangment( + possible_brs, + reac, + prod, + fbonds=[fbond], + bbonds=[bbond1, bbond2], + ) + + # Make and break two bonds, all of the same type + two_same_possibles = itertools.combinations(all_possible_bbonds[0], 2) + possibles = itertools.product( + bbond_atom_type_fbonds, two_same_possibles + ) + + for fbond, (bbond1, bbond2) in possibles: + possible_brs = add_bond_rearrangment( + possible_brs, + reac, + prod, + fbonds=[fbond], + bbonds=[bbond1, bbond2], + ) + + return possible_brs + + +def get_fbonds_bbonds_2b2f( + reac, + prod, + possible_brs, + all_possible_bbonds, + all_possible_fbonds, + possible_bbond_and_fbonds, + bbond_atom_type_fbonds, + fbond_atom_type_bbonds, +): + logger.info( + "Getting possible 2 breaking and 2 forming bond rearrangements" + ) + + if len(all_possible_bbonds) == 2 and len(all_possible_fbonds) == 2: + # Make two bonds and break two bonds, all of different types + possibles = itertools.product( + all_possible_fbonds[0], + all_possible_fbonds[1], + all_possible_bbonds[0], + all_possible_bbonds[1], + ) + + for fbond1, fbond2, bbond1, bbond2 in possibles: + possible_brs = add_bond_rearrangment( + possible_brs, + reac, + prod, + fbonds=[fbond1, fbond2], + bbonds=[bbond1, bbond2], + ) + + elif len(all_possible_bbonds) == 2 and len(all_possible_fbonds) == 1: + # Make two bonds of the same type, break two bonds of different types + two_same_possibles = itertools.combinations(all_possible_fbonds[0], 2) + possibles = itertools.product( + all_possible_bbonds[0], all_possible_bbonds[1], two_same_possibles + ) + + for bbond1, bbond2, (fbond1, fbond2) in possibles: + possible_brs = add_bond_rearrangment( + possible_brs, + reac, + prod, + fbonds=[fbond1, fbond2], + bbonds=[bbond1, bbond2], + ) + + elif len(all_possible_bbonds) == 1 and len(all_possible_fbonds) == 2: + # Make two bonds of different types, break two bonds of the same type + two_same_possibles = itertools.combinations(all_possible_bbonds[0], 2) + possibles = itertools.product( + all_possible_fbonds[0], all_possible_fbonds[1], two_same_possibles + ) + + for fbond1, fbond2, (bbond1, bbond2) in possibles: + possible_brs = add_bond_rearrangment( + possible_brs, + reac, + prod, + fbonds=[fbond1, fbond2], + bbonds=[bbond1, bbond2], + ) + + elif len(all_possible_bbonds) == 1 and len(all_possible_fbonds) == 1: + two_f_possibles = itertools.combinations(all_possible_fbonds[0], 2) + two_b_possibles = itertools.combinations(all_possible_bbonds[0], 2) + possibles = itertools.product(two_f_possibles, two_b_possibles) + + for (fbond1, fbond2), (bbond1, bbond2) in possibles: + # Make two bonds of the same type, break two bonds of another type + possible_brs = add_bond_rearrangment( + possible_brs, + reac, + prod, + fbonds=[fbond1, fbond2], + bbonds=[bbond1, bbond2], + ) + + for bbonds, fbonds in possible_bbond_and_fbonds: + # Make one bonds of one type, break one bond of another type, make + # and break a bond of a third type + possibles = itertools.product( + all_possible_fbonds[0], fbonds, all_possible_bbonds[0], bbonds + ) + + for fbond1, fbond2, bbond1, bbond2 in possibles: + possible_brs = add_bond_rearrangment( + possible_brs, + reac, + prod, + fbonds=[fbond1, fbond2], + bbonds=[bbond1, bbond2], + ) + + # Make a bond of one type, make and break two bonds of another type + two_b_possibles = itertools.combinations(all_possible_bbonds[0], 2) + possibles = itertools.product( + all_possible_fbonds[0], bbond_atom_type_fbonds, two_b_possibles + ) + + for fbond1, fbond2, (bbond1, bbond2) in possibles: + possible_brs = add_bond_rearrangment( + possible_brs, + reac, + prod, + fbonds=[fbond1, fbond2], + bbonds=[bbond1, bbond2], + ) + + two_f_possibles = itertools.combinations(all_possible_fbonds[0], 2) + possibles = itertools.product( + all_possible_bbonds[0], fbond_atom_type_bbonds, two_f_possibles + ) + + for bbond1, bbond2, (fbond1, fbond2) in possibles: + # Break a bond of one type, make two and break one bond of another + # type + possible_brs = add_bond_rearrangment( + possible_brs, + reac, + prod, + fbonds=[fbond1, fbond2], + bbonds=[bbond1, bbond2], + ) + + elif len(all_possible_bbonds) == 0 and len(all_possible_fbonds) == 0: + possibles_b_f = itertools.combinations(possible_bbond_and_fbonds, 2) + + for (bbonds1, fbonds1), (bbonds2, fbonds2) in possibles_b_f: + # Make and break a bond of one type, make and break a bond of + # another type + possibles = itertools.product(fbonds1, bbonds1, fbonds2, bbonds2) + + for fbond1, bbond1, fbond2, bbond2 in possibles: + possible_brs = add_bond_rearrangment( + possible_brs, + reac, + prod, + fbonds=[fbond1, fbond2], + bbonds=[bbond1, bbond2], + ) + + for bbonds, fbonds in possible_bbond_and_fbonds: + # Make two and break two bonds, all of the same type + possibles = itertools.product( + itertools.combinations(fbonds, 2), + itertools.combinations(bbonds, 2), + ) + + for (fbond1, fbond2), (bbond1, bbond2) in possibles: + possible_brs = add_bond_rearrangment( + possible_brs, + reac, + prod, + fbonds=[fbond1, fbond2], + bbonds=[bbond1, bbond2], + ) + + return possible_brs + + +def strip_equiv_bond_rearrs(possible_brs, mol, depth=6): + """Remove any bond rearrangement from possible_brs for which + there is already an equivalent in the unique_bond_rearrangements list + + --------------------------------------------------------------------------- + Arguments: + possible_brs (list(BondRearrangement)): + mol (autode.species.Complex): Reactant + + Keyword Arguments: + depth (int): Depth of neighbour list that must be identical for a set + of atoms to be considered equivalent (default: {6}) + + Returns: + (list(BondRearrangement)): stripped list of BondRearrangement objects + """ + logger.info( + "Stripping the forming and breaking bond list by discarding " + "rearrangements with equivalent atoms" + ) + + unique_brs = [] + + for br in possible_brs: + bond_rearrang_is_unique = True + + # Compare bond_rearrang to all those already considered to be unique, + for unique_br in unique_brs: + if unique_br.get_active_atom_neighbour_lists( + species=mol, depth=depth + ) == br.get_active_atom_neighbour_lists(species=mol, depth=depth): + bond_rearrang_is_unique = False + + if bond_rearrang_is_unique: + unique_brs.append(br) + + logger.info( + f"Stripped {len(possible_brs) - len(unique_brs)} " + "bond rearrangements" + ) + return unique_brs + + +def prune_small_ring_rearrs(possible_brs, mol): + """ + Remove any bond rearrangements that go via small (3, 4) rings if there is + an alternative that goes vie + + --------------------------------------------------------------------------- + Arguments: + possible_brs (list(BondRearrangement)): + + mol (autode.species.Complex): Reactant + """ + small_ring_sizes = (3, 4) + + if not Config.skip_small_ring_tss: + logger.info("Not pruning small ring TSs") + return None + + # Membered-ness of rings in each bond rearrangement + n_mem_rings = [br.n_membered_rings(mol) for br in possible_brs] + + # Unique elements involved in each bond rearrangement + elems = [ + set( + mol.atoms[i].label + for i in range(mol.n_atoms) + if i in br.active_atoms + ) + for br in possible_brs + ] + + logger.info( + f"Pruning {len(possible_brs)} to remove any " + f"{small_ring_sizes}-membered rings where others are possible" + ) + + excluded_idxs = [] + for i, br in enumerate(possible_brs): + logger.info( + f"Checking bond rearrangement {i} with rings:" + f" {n_mem_rings[i]} and atom indexes: {br}" + ) + + # Only consider brs with at least one small ring + if not any(n_mem in small_ring_sizes for n_mem in n_mem_rings[i]): + continue + + # Check against all other rearrangements + for j, other_br in enumerate(possible_brs): + # Only consider brs with the same set of elements + if elems[i] != elems[j]: + continue + + # Needs to have the same number of rings + if len(n_mem_rings[i]) != len(n_mem_rings[j]): + continue + + # Exclude i if j has a larger smallest ring size + if min(n_mem_rings[i]) < min(n_mem_rings[j]): + excluded_idxs.append(i) + break + + logger.info( + f"Excluding {len(excluded_idxs)} bond rearrangements based on " + f"small rings" + ) + + # Delete the excluded bond rearrangements (sorted high -> low, so the + # idxs remain the same while deleting) + for idx in sorted(excluded_idxs, reverse=True): + del possible_brs[idx] + + return None + + +class BondRearrangement: + def __eq__(self, other): + return self.fbonds == other.fbonds and self.bbonds == other.bbonds + + def __str__(self): + return "_".join(f"{bond[0]}-{bond[1]}" for bond in self.all) + + def get_active_atom_neighbour_lists(self, species, depth): + """ + Get neighbour lists of all the active atoms in the molecule + (reactant complex) + + ----------------------------------------------------------------------- + Arguments: + species (autode.species.Species | autode.species.Complex): + depth (int): Depth of the neighbour list to consider + + Returns: + (list(list(str))): + """ + + def nl(idx): + mol_idxs = None + + try: + mol_idxs = next( + species.atom_indexes(i) + for i in range(species.n_molecules) + if idx in species.atom_indexes(i) + ) + + except (StopIteration, AttributeError): + logger.warning("Active atom index not found in any molecules") + + nl_labels = get_neighbour_list( + species, atom_i=idx, index_set=mol_idxs + ) + return nl_labels[:depth] + + return [nl(idx) for idx in self.active_atoms] + + def n_membered_rings(self, mol): + """ + Find the membered-ness of the rings involved in this bond rearrangement + will add the forming bonds to the graph to determine + + ----------------------------------------------------------------------- + Arguments: + (autode.species.Species): + + Returns: + (list(int)): + """ + assert mol.graph is not None + graph = mol.graph.copy() + + for fbond in self.fbonds: + if fbond not in graph.edges: + graph.add_edge(*fbond) + + rings = find_cycles(graph) + n_mem_rings = [] + + # Full enumeration over all atoms and rings - could be faster.. + for ring in rings: + for atom_idx in self.active_atoms: + if atom_idx in ring: + # This ring has at least one active atom in + n_mem_rings.append(len(ring)) + + # don't add the same ring more than once + break + + return n_mem_rings + + @property + def fatoms(self): + """Unique atoms indexes involved in forming bonds""" + return list(sorted(set([i for bond in self.fbonds for i in bond]))) + + @property + def batoms(self): + """Unique atoms indexes involved in breaking bonds""" + return list(sorted(set([i for bond in self.bbonds for i in bond]))) + + @property + def active_atoms(self): + """Unique atom indexes in forming or breaking bonds""" + return list(sorted(set(a for b in self.all for a in b))) + + @property + def n_fbonds(self): + return len(self.fbonds) + + @property + def n_bbonds(self): + return len(self.bbonds) + + def __init__(self, forming_bonds=None, breaking_bonds=None): + """ + Bond rearrangement + + ----------------------------------------------------------------------- + Keyword Arguments: + forming_bonds (list(tuple(int))): List of atom pairs that are + forming in this reaction + + breaking_bonds (list(tuple(int))): List of atom pairs that are + breaking in the reaction + """ + + self.fbonds = forming_bonds if forming_bonds is not None else [] + self.bbonds = breaking_bonds if breaking_bonds is not None else [] + + self.all = self.fbonds + self.bbonds diff --git a/autodE/source/autode/bonds.py b/autodE/source/autode/bonds.py new file mode 100644 index 0000000000000000000000000000000000000000..46c827f6fd8021d9b1477ea78b3f19b4cd2ced4c --- /dev/null +++ b/autodE/source/autode/bonds.py @@ -0,0 +1,87 @@ +class ScannedBond: + def __str__(self): + i, j = self.atom_indexes + return f"{i}-{j}" + + def __getitem__(self, item): + return self.atom_indexes[item] + + @property + def dr(self): + """Change in distance for this bond (∆r / Å)""" + if self.curr_dist is None or self.final_dist is None: + return 0 + + return self.final_dist - self.curr_dist + + def __init__(self, atom_indexes): + """ + Bond with a current and final distance which will be scanned over + + ----------------------------------------------------------------------- + Arguments: + atom_indexes (tuple(int)): Atom indexes that make this + 'bond' e.g. (0, 1) + """ + assert len(atom_indexes) == 2 + + self.atom_indexes = atom_indexes + + self.curr_dist = None + self.final_dist = None + + self.forming = False + self.breaking = False + + +class FormingBond(ScannedBond): + def __init__(self, atom_indexes, species, final_species=None): + """ + Forming bond with current and final distances + + ----------------------------------------------------------------------- + Arguments: + atom_indexes (tuple(int)): + + species (autode.species.Species): + """ + super().__init__(atom_indexes) + self.forming = True + + i, j = self.atom_indexes + self.curr_dist = species.distance(i=i, j=j) + + if final_species is None: + self.final_dist = species.atoms.eqm_bond_distance(i, j) + else: + self.final_dist = final_species.distance(*atom_indexes) + + +class BreakingBond(ScannedBond): + def __init__(self, atom_indexes, species, final_species=None): + """ + Form a breaking bond with current and final distances + + ----------------------------------------------------------------------- + Arguments: + atom_indexes (tuple(int)): + + species (autode.species.Species): + + final_species (autode.species.Species | None): + """ + super().__init__(atom_indexes) + self.breaking = True + + self.curr_dist = species.distance(*self.atom_indexes) + + if final_species is None: + self.final_dist = 2.0 * self.curr_dist + + else: + # Take the smallest possible final distance, thus the shortest + # path to traverse + self.final_dist = min( + final_species.distance(*self.atom_indexes), + 2.0 * self.curr_dist, + ) diff --git a/autodE/source/autode/bracket/__init__.py b/autodE/source/autode/bracket/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..1d9d48f7836edf41d3595be89817f21345da5b0d --- /dev/null +++ b/autodE/source/autode/bracket/__init__.py @@ -0,0 +1,3 @@ +from autode.bracket.dhs import DHS, DHSGS + +__all__ = ["DHS", "DHSGS"] diff --git a/autodE/source/autode/bracket/base.py b/autodE/source/autode/bracket/base.py new file mode 100644 index 0000000000000000000000000000000000000000..60a9e9b207c1a6a8426a57b7a1950e4526a00041 --- /dev/null +++ b/autodE/source/autode/bracket/base.py @@ -0,0 +1,316 @@ +from typing import Union, Optional, TYPE_CHECKING +from abc import ABC, abstractmethod + +from autode.values import Distance, GradientRMS +from autode.bracket.imagepair import EuclideanImagePair +from autode.log import logger +from autode.utils import work_in +from autode import Config + +if TYPE_CHECKING: + from autode.species.species import Species + from autode.wrappers.methods import Method + + +class BaseBracketMethod(ABC): + """ + Base class for all bracketing methods + """ + + def __init__( + self, + initial_species: "Species", + final_species: "Species", + maxiter: int = 300, + dist_tol: Union[Distance, float] = Distance(1.0, "ang"), + gtol: Union[GradientRMS, float] = GradientRMS(1.0e-3, "ha/ang"), + cineb_at_conv: bool = False, + barrier_check: bool = True, + ): + """ + Bracketing methods find transition state by using two images, one + for the reactant state and another representing the product state. + These methods move the images continuously until they converge at + the transition state (TS), i.e. they bracket the TS from both ends. + It is optionally possible to run a CI-NEB (with only one intervening + image) from the end-points of a converged bracketing method + calculation to get much closer to the actual TS. + + Args: + initial_species: The "reactant" species + final_species: The "product" species + maxiter: Maximum number of energy-gradient evaluations + dist_tol: The distance tolerance at which the method + will stop, in units of Å if not given + gtol: Gradient tolerance for optimisation steps in + the method, units Ha/Å if not given + cineb_at_conv: Whether to run a CI-NEB with from the final points + barrier_check: Whether to stop the calculation if one image is + detected to have jumped over the barrier. Do not + turn this off unless you are absolutely sure! + """ + # imgpair type must be set by subclass + self.imgpair: Optional["EuclideanImagePair"] = None + self._species: "Species" = initial_species.copy() + + self._maxiter = int(maxiter) + self._dist_tol = Distance(dist_tol, units="ang") + self._gtol = GradientRMS(gtol, units="Ha/ang") + + self._should_run_cineb = bool(cineb_at_conv) + self._barrier_check = bool(barrier_check) + + @property + def _name(self) -> str: + """Name of the current bracketing method, obtained from class name""" + return type(self).__name__ + + @property + def ts_guess(self) -> Optional["Species"]: + """Get the TS guess from image-pair""" + assert self.imgpair is not None, "Must have an image pair for TS guess" + return self.imgpair.ts_guess + + @property + def converged(self) -> bool: + """Whether the bracketing method has converged or not""" + assert self.imgpair is not None, "Must have an image pair" + + # NOTE: Usually geometry optimisation is done in separate + # micro-iters, so gradient is checked elsewhere + return self.imgpair.dist <= self._dist_tol + + @property + @abstractmethod + def _macro_iter(self) -> int: + """The number of macro-iterations run with this method""" + + @property + @abstractmethod + def _micro_iter(self) -> int: + """Total number of micro-iterations run with this method""" + + @abstractmethod + def _initialise_run(self) -> None: + """Initialise the bracketing method run""" + + @abstractmethod + def _step(self) -> None: + """ + One step of the bracket method, with one macro-iteration + and multiple micro-iterations. This must also set new + coordinates for the next step + """ + + def _log_convergence(self) -> None: + """ + Log the convergence of the bracket method. Only logs macro-iters, + subclasses may implement further logging for micro-iters + """ + assert self.imgpair is not None, "Must have an image pair to log" + + logger.info( + f"{self._name} Macro-iteration #{self._macro_iter}: " + f"Distance = {self.imgpair.dist:.4f}; Energy (initial species) = " + f"{self.imgpair.left_coords.e:.6f}; Energy (final species) = " + f"{self.imgpair.right_coords.e:.6f}" + ) + + @property + def _exceeded_maximum_iteration(self) -> bool: + """Whether it has exceeded the number of maximum micro-iterations""" + if self._micro_iter >= self._maxiter: + logger.error( + f"Reached the maximum number of micro-iterations " + f"*{self._maxiter}*" + ) + return True + else: + return False + + def calculate( + self, + method: "Method", + n_cores: Optional[int] = None, + ) -> None: + """ + Run the bracketing method calculation using the method for + energy/gradient calculation, with n_cores. Runs CI-NEB at + the end if requested; then save the .xyz trajectories, + plot the energies and finally save the peak as TS guess. + This function should be called only once! + + Args: + method (Method): Method used for calculating energy/gradients + n_cores (int): Number of cores to use for calculation + """ + + @work_in(self._name.lower()) + def run(): + self._calculate(method, n_cores) + + run() + return None + + def _calculate( + self, + method: "Method", + n_cores: Optional[int] = None, + ) -> None: + """ + Actually runs the calculation, it is wrapped around in calculate() + so that the results are placed in a sub-folder + """ + assert self.imgpair is not None, "Must have set image pair" + + n_cores = Config.n_cores if n_cores is None else int(n_cores) + self.imgpair.set_method_and_n_cores(method, n_cores) + self.imgpair.initialise_trj( + f"{self._name}_left_history.zip", f"{self._name}_right_history.zip" + ) + self._initialise_run() + + logger.info(f"Starting {self._name} method to find transition state") + + while not self.converged: + self._step() + + if self.imgpair.has_jumped_over_barrier: + # TODO: implement image pair regeneration + logger.error( + "One image has probably jumped over the barrier, in" + f" {self._name} TS search. Please check the" + f" results carefully" + ) + if self._barrier_check: + logger.info(f"Stopping {self._name} calculation") + break + + if self._exceeded_maximum_iteration: + break + + self._log_convergence() + + # exited main loop, run CI-NEB if required and bracket converged + if self._should_run_cineb: + if self.converged and not self.imgpair.has_jumped_over_barrier: + self.run_cineb() + else: + logger.warning( + f"{self._name} calculation has not converged" + f" properly or one side has jumped over the barrier," + f" skipping CI-NEB run" + ) + + logger.info( + f"Finished {self._name} procedure in {self._macro_iter} " + f"macro-iterations consisting of {self._micro_iter} micro-" + f"iterations (optimiser steps). {self._name} is " + f"{'converged' if self.converged else 'not converged'}" + ) + self.imgpair.close_trj() + self.print_geometries() + self.plot_energies() + if self.converged and self.ts_guess is not None: + self.ts_guess.print_xyz_file(filename=f"{self._name}_ts_guess.xyz") + return None + + def print_geometries( + self, + init_trj_filename: Optional[str] = None, + final_trj_filename: Optional[str] = None, + total_trj_filename: Optional[str] = None, + ) -> None: + """ + Write trajectories as *.xyz files, one for the initial species, + one for final species, and one for the whole trajectory, including + any CI-NEB run from the final end points. The default names for + the trajectories must be set in individual subclasses + """ + assert self.imgpair is not None, "Must have an image pair to plot" + + init_trj_filename = ( + init_trj_filename + if init_trj_filename is not None + else f"initial_species_{self._name}.trj.xyz" + ) + final_trj_filename = ( + final_trj_filename + if final_trj_filename is not None + else f"final_species_{self._name}.trj.xyz" + ) + total_trj_filename = ( + total_trj_filename + if total_trj_filename is not None + else f"total_trajectory_{self._name}.trj.xyz" + ) + self.imgpair.print_geometries( + init_trj_filename, final_trj_filename, total_trj_filename + ) + + return None + + def plot_energies( + self, + filename: Optional[str] = None, + distance_metric: str = "relative", + ) -> None: + """ + Plot the energies of the bracket method run, taking + into account any CI-NEB interpolation that may have been + done. + + The distance metric chooses what the x-axis means; + "relative" means that the points will be plotted in the order + in which they appear in the total history, and the x-axis + numbers will represent the relative distances between two + adjacent points (giving an approximate reaction coordinate). + "from_start" will calculate the distance of each point from + the starting reactant structure and use that as the x-axis + position. If distance metric is set to "index", then the x-axis + will simply be integer numbers representing each point in order + + Args: + filename (str|None): Name of the file (optional) + distance_metric (str): "relative" or "from_start" or "index" + """ + assert self.imgpair is not None, "Must have an image pair to plot" + + filename = ( + filename + if filename is not None + else f"{self._name}_path_energy_plot.pdf" + ) + self.imgpair.plot_energies(filename, distance_metric) + return None + + def run_cineb(self) -> None: + """ + Run CI-NEB from the end-points of a converged bracketing + calculation. Uses only one intervening image for the + CI-NEB calculation (which is okay as the bracketing method + should bring the ends very close to the TS). The result from + the CI-NEB calculation is stored as coordinates. + """ + assert self.imgpair is not None, "Must have image pair to run CINEB" + + if not self._micro_iter > 0: + logger.error( + f"Must run {self._name} calculation before" + f"running the CI-NEB calculation" + ) + return None + + if not self.converged or self.imgpair.dist > 2.0: + logger.warning( + f"{self._name} method has not converged sufficiently," + f" running a CI-NEB calculation now may cause errors." + f" Please check results carefully." + ) + else: + logger.info( + f"{self._name} has converged, running CI-NEB" + f" calculation from the end points" + ) + self.imgpair.run_cineb_from_end_points() + return None diff --git a/autodE/source/autode/bracket/dhs.py b/autodE/source/autode/bracket/dhs.py new file mode 100644 index 0000000000000000000000000000000000000000..bb3df016e07ff9bef002822439212f91edbeb5dc --- /dev/null +++ b/autodE/source/autode/bracket/dhs.py @@ -0,0 +1,764 @@ +""" +Dewar-Healy-Stewart Method for finding transition states + +Also implements DHS-GS, CI-DHS and CI-DHS-GS methods + +[1] M. J. S. Dewar, E. Healy, J. Chem. Soc. Farady Trans. 2, 1984, 80, 227-233 +""" +import numpy as np +from typing import Tuple, Union, Optional, Any, TYPE_CHECKING +from enum import Enum + +from autode.values import Distance, Angle, GradientRMS, PotentialEnergy +from autode.bracket.imagepair import EuclideanImagePair +from autode.opt.coordinates import CartesianCoordinates +from autode.opt.optimisers.utils import TruncatedTaylor +from autode.opt.optimisers.hessian_update import BFGSSR1Update +from autode.bracket.base import BaseBracketMethod +from autode.opt.optimisers import RFOptimiser, ConvergenceParams +from autode.exceptions import OptimiserStepError +from autode.log import logger + +if TYPE_CHECKING: + from autode.species.species import Species + from autode.wrappers.methods import Method + from autode.opt.optimisers.base import ConvergenceTolStr + + +class DistanceConstrainedOptimiser(RFOptimiser): + """ + Constrained optimisation of a molecule, with the Euclidean + distance being kept constrained to a fixed value. The + constraint is enforced by a Lagrangian multiplier. An optional + linear search can be done to speed up convergence. + + Same concept as that used in the corrector step of + Gonzalez-Schlegel second-order IRC integrator. However, + current implementation is modified to take steps within + a trust radius. + + [1] C. Gonzalez, H. B. Schlegel, J. Chem. Phys., 90, 1989, 2154 + """ + + def __init__( + self, + pivot_point: Optional[CartesianCoordinates], + init_trust: float = 0.1, + line_search: bool = True, + angle_thresh: Angle = Angle(5, units="deg"), + old_coords_read_hess: Optional[CartesianCoordinates] = None, + *args, + **kwargs, + ): + """ + Initialise a distance constrained optimiser. The pivot point + is the point against which the distance is constrained. Optionally + a linear search can be used to attempt to speed up convergence, but + it may not improve performance in all cases. + + Args: + init_trust: Initial trust radius in Angstrom + pivot_point: Coordinates of the pivot point + line_search: Whether to use linear search + angle_thresh: An angle threshold above which linear search + will be rejected (in Degrees) + old_coords_read_hess: Old coordinate with hessian which will + be used to obtain the initial hessian by a + Hessian update scheme + """ + kwargs.pop("init_alpha", None) + super().__init__(*args, init_alpha=init_trust, **kwargs) + + if not isinstance(pivot_point, CartesianCoordinates): + raise NotImplementedError( + "Internal coordinates are not implemented in distance" + "constrained optimiser right now, please use Cartesian" + ) + self._pivot = pivot_point + self._do_line_search = bool(line_search) + self._angle_thresh = Angle(angle_thresh, units="deg").to("radian") + self._target_dist: Optional[float] = None + + self._hessian_update_types = [BFGSSR1Update] + self._old_coords = old_coords_read_hess + + def _initialise_run(self) -> None: + """Initialise self._coords, gradient and hessian""" + assert self._species is not None, "Must have a species to init run" + + self._coords = CartesianCoordinates(self._species.coordinates) + self._target_dist = np.linalg.norm(self.dist_vec) + self._update_gradient_and_energy() + + # Update the Hessian from old coordinates, if exists + if self._old_coords is not None and self._old_coords.h is not None: + assert isinstance(self._old_coords, CartesianCoordinates) + self._coords.update_h_from_old_h( + self._old_coords, self._hessian_update_types + ) + else: + # no hessian available, use low level method + self._coords.update_h_from_cart_h(self._low_level_cart_hessian) + self._coords.make_hessian_positive_definite() + + @property + def converged(self) -> bool: + """Has the optimisation converged""" + assert self._coords is not None + + # Check only the tangential component of gradient + g_tau = self.tangent_grad + rms_g_tau = np.sqrt(np.mean(np.square(g_tau))) + max_g_tau = np.max(np.abs(g_tau)) + + curr_params = self._history.conv_params() + curr_params.rms_g = GradientRMS(rms_g_tau, "Ha/ang") + curr_params.max_g = GradientRMS(max_g_tau, "Ha/ang") + return self.conv_tol.meets_criteria(curr_params) + + @property + def tangent_grad(self) -> np.ndarray: + """ + Obtain the component of atomic gradients tangent to the distance + vector between current coords and pivot point + """ + assert self._coords is not None and self._coords.g is not None + + grad = self._coords.g + # unit vector in the direction of distance vector + d_hat = self.dist_vec / np.linalg.norm(self.dist_vec) + tangent_grad = grad - (grad.dot(d_hat)) * d_hat + return tangent_grad + + @property + def dist_vec(self) -> np.ndarray: + """ + Get the distance vector (p) between the current coordinates + and the pivot point + + Returns: + (np.ndarray): + """ + return np.array(self._coords - self._pivot) + + def _update_gradient_and_energy(self) -> None: + # Hessian update is done after en grad calculation, not in step + # so that it is present in the final converged coords, which + # can be used to start off the next batch of optimisation + super()._update_gradient_and_energy() + if self.iteration != 0: + assert self._coords is not None, "Must have set coordinates" + self._coords.update_h_from_old_h( + self._history.penultimate, self._hessian_update_types + ) + + def _step(self) -> None: + """ + A step that maintains the distance of the coordinate from + the pivot point. A line search is done if it is not the first + iteration (and it has not been turned off), and then a + quasi-Newton step with a Lagrangian constraint for the distance + is taken (falls back to steepest descent with projected gradient + if this fails). + """ + assert self._coords is not None, "Must have set coordinates" + + # if energy is rising, interpolate halfway between last step + if self.iteration >= 1 and ( + self.last_energy_change > PotentialEnergy(5, "kcalmol") + ): + logger.warning("Energy rising, going back half a step") + half_interp = (self._coords + self._history.penultimate) / 2 + self._coords = half_interp + return None + + if self.iteration >= 1 and self._do_line_search: + coords, grad = self._line_search_on_sphere() + else: + coords, grad = self._coords, self._coords.g + + try: + step = self._get_lagrangian_step(coords, grad) + logger.info( + f"Taking a quasi-Newton step: {np.linalg.norm(step):.3f} Å" + ) + except OptimiserStepError: + step = self._get_sd_step(coords, grad) + logger.warning( + f"Failed to take quasi-Newton step, taking steepest " + f"descent step instead: {np.linalg.norm(step):.3f} Å" + ) + + # the step is on the interpolated coordinates (if done) + actual_step = (coords + step) - self._coords + self._coords = self._coords + actual_step + return None + + def _get_sd_step(self, coords, grad) -> np.ndarray: + """ + Obtain a steepest descent step minimising the tangential + gradient. This step cannot perfectly maintain the same + distance from pivot point. The step size is at most half + of the trust radius. + + Args: + coords: Previous coordinates + grad: Previous gradient + + Returns: + (np.ndarray): Step in Cartesian coordinates + """ + dist_vec = coords - self._pivot + dist_hat = dist_vec / np.linalg.norm(dist_vec) + perp_grad = grad - np.dot(grad, dist_hat) * dist_hat + + sd_step = -perp_grad + if np.linalg.norm(sd_step) > self.alpha / 2: + sd_step *= (self.alpha / 2) / np.linalg.norm(sd_step) + + return sd_step + + def _get_lagrangian_step(self, coords, grad) -> np.ndarray: + """ + Obtain the step that will minimise the gradient tangent to + the distance vector from pivot point, while maintaining the + same distance from pivot point. Takes the step within current + trust radius. + + Args: + coords: Previous coordinate (either from quasi-NR step + or from linear search) + grad: Previous gradient (either from quasi-NR or linear + search) + + Returns: + (np.ndarray): Step in cartesian (or mw-cartesian) coordinates + + Raises: + OptimiserStepError: If scipy fails to calculate constrained step + """ + from scipy.optimize import minimize + + assert self._coords is not None, "Must have set coordinates" + + # NOTE: Since the linear interpolation should produce a point + # in the vicinity of the last two points, it seems reasonable to + # also use the hessian from the last point in the case of linear + # interpolation being done + taylor_pes = TruncatedTaylor(coords, grad, self._coords.h) + + def step_size_constr(x): + """step size must be <= trust radius""" + step_est = x - coords + # inequality constraint, should be > 0 + return self.alpha - np.linalg.norm(step_est) + + def lagrangian_constr(x): + """step must maintain same distance from pivot""" + p = x - self._pivot + return np.linalg.norm(p) - self._target_dist + + constrs = ( + {"type": "ineq", "fun": step_size_constr}, + {"type": "eq", "fun": lagrangian_constr}, + ) + # NOTE: The Lagrangian constraint should be ideally calculated using + # a multiplier which can be found by a 1-D root search, however, it + # seems to produce really large steps. So instead the constraint + # and the trust radius are both enforced by doing a constrained + # optimisation on the truncated Taylor surface, which should give + # a quadratic step that follows the constraint and is within trust + # radius + + res = minimize( + fun=taylor_pes.value, + x0=np.array(self._coords), + method="slsqp", + jac=taylor_pes.gradient, + options={"maxiter": 2000}, + constraints=constrs, + ) + + if not res.success: + raise OptimiserStepError( + f"Unable to obtain distance-constrained step\nResult: {res}" + ) + + step = res.x - coords + return step + + def _line_search_on_sphere( + self, + ) -> Tuple[Optional[CartesianCoordinates], Optional[np.ndarray]]: + """ + Linear search on a hypersphere of radius equal to the target + distance. + + Returns: + (Tuple): Interpolated coordinates and gradient as tuple + """ + assert self._coords is not None, "Must have set coords to line search" + + # Eq (12) to (15) in J. Chem. Phys., 90, 1989, 2154 + # Notation follows the publication + last_coords = self._history[-2] + assert last_coords is not None + + p_prime = self.dist_vec + g_prime_per = self._coords.g - p_prime * ( + np.dot(self._coords.g, p_prime) / np.dot(p_prime, p_prime) + ) + g_prime_per = np.linalg.norm(g_prime_per) + p_prime_prime = np.array(last_coords - self._pivot) + g_prime_prime_per = last_coords.g - p_prime_prime * ( + np.dot(last_coords.g, p_prime_prime) + / np.dot(p_prime_prime, p_prime_prime) + ) + g_prime_prime_per = np.linalg.norm(g_prime_prime_per) + cos_theta_prime = np.dot(p_prime, p_prime_prime) / ( + np.linalg.norm(p_prime) * np.linalg.norm(p_prime_prime) + ) + assert -1 < cos_theta_prime < 1 + theta_prime = np.arccos(cos_theta_prime) + theta = (g_prime_prime_per * theta_prime) / ( + g_prime_prime_per - g_prime_per + ) + + p_interp = p_prime_prime * ( + np.cos(theta) + - np.sin(theta) * np.cos(theta_prime) / np.sin(theta_prime) + ) + p_interp += p_prime * (np.sin(theta) / np.sin(theta_prime)) + + g_interp = last_coords.g * (1 - theta / theta_prime) + g_interp += self._coords.g * (theta / theta_prime) + + x_interp = self._pivot + p_interp + + step_size = np.linalg.norm(x_interp - self._coords) + angle_change = abs(theta_prime - theta) + if ( + ( + angle_change > self._angle_thresh + and abs(theta) > self._angle_thresh + ) + or ( + theta < 0 # extrapolating instead of interpolating + and theta_prime < self._angle_thresh + ) + or (step_size > self.alpha) # larger than trust radius + ): + logger.info("Linear interpolation step is unstable, skipping") + return self._coords, self._coords.g + + logger.info(f"Linear interpolation - step size: {step_size:.3f} Å") + + return x_interp, g_interp + + +class ImageSide(Enum): + """Represents one side of the image-pair""" + + left = 0 + right = 1 + + +class DHSImagePair(EuclideanImagePair): + """ + Image-pair used for Dewar-Healy-Stewart (DHS) method to + find transition states. In this method, only one side is + modified in a step, so functions to work with only one + side is present here + """ + + @property + def ts_guess(self) -> Optional["Species"]: + """ + In DHS method, the images can only rise in energy; therefore, + the highest energy image is the ts_guess. If CI-NEB is run, + then that result is returned instead + """ + tmp_spc = self._left_image.new_species(name="peak") + + if self._cineb_coords is not None: + assert ( + self._cineb_coords is not None + and self._cineb_coords.e + and self._cineb_coords.g is not None + ) + tmp_spc.coordinates = self._cineb_coords + tmp_spc.energy = self._cineb_coords.e + tmp_spc.gradient = self._cineb_coords.g.reshape(-1, 3).copy() + return tmp_spc + + # NOTE: Even though the final points are probably the highest + # this is not guaranteed, due to the probability of one end + # jumping over the barrier. So we iterate through all coords + + energies = [] + max_e = PotentialEnergy(-np.inf) + peak_coords: Optional[CartesianCoordinates] = None + for coord in self._total_history: + energies.append(coord.e) + if coord.e is None: + logger.error( + "Energy values are missing in the trajectory of this" + " image-pair. Unable to obtain transition state guess" + ) + return None + if coord.e > max_e: + max_e = coord.e + peak_coords = coord + + assert peak_coords is not None + tmp_spc.coordinates = peak_coords + tmp_spc.energy = peak_coords.e + if peak_coords.g is not None: + tmp_spc.gradient = peak_coords.g.reshape(-1, 3).copy() + return tmp_spc + + def get_coord_by_side(self, side: ImageSide) -> CartesianCoordinates: + """For external usage, supplies the coordinate object by side""" + if side == ImageSide.left: + return self.left_coords + elif side == ImageSide.right: + return self.right_coords + else: + raise ValueError + + def put_coord_by_side( + self, new_coord: CartesianCoordinates, side: ImageSide + ) -> None: + """ + For external usage, puts the new coordinate in appropriate side + + Args: + new_coord (CartesianCoordinates): New set of coordinates + side (ImageSide): left or right + """ + if side == ImageSide.left: + self.left_coords = new_coord + elif side == ImageSide.right: + self.right_coords = new_coord + else: + raise ValueError + return None + + def get_last_step_by_side(self, side: ImageSide) -> Optional[np.ndarray]: + """ + Obtain the last step on the provided side (for the Growing + String like step) + """ + if side == ImageSide.left: + hist = self._left_history + elif side == ImageSide.right: + hist = self._right_history + else: + raise ValueError + + if len(hist) < 2: + return None + return hist.final - hist.penultimate + + def get_dhs_step_by_side( + self, side: ImageSide, step_size: float + ) -> np.ndarray: + """ + Obtain the DHS extrapolation step on the specified side, + with the specified step size + + Args: + side (ImageSide): left or right + step_size (float): Step size in Angstrom + + Returns: + (np.ndarray): The step + """ + dhs_step = self.dist_vec * (step_size / self.dist) + if side == ImageSide.left: + dhs_step *= -1.0 + elif side == ImageSide.right: + pass + else: + raise ValueError + + return dhs_step + + +class DHS(BaseBracketMethod): + """ + Dewar-Healy-Stewart method for finding transition states, + from the reactant and product structures + """ + + def __init__( + self, + initial_species: "Species", + final_species: "Species", + large_step: Union[Distance, float] = Distance(0.2, "ang"), + small_step: Union[Distance, float] = Distance(0.05, "ang"), + switch_thresh: Union[Distance, float] = Distance(1.5, "ang"), + conv_tol: Union["ConvergenceParams", "ConvergenceTolStr"] = "loose", + **kwargs, + ): + """ + Dewar-Healy-Stewart method to find transition states. The distance + tolerance convergence criteria should not be much lower than 0.5 Angstrom + as DHS is unstable when the distance is low, and there is a tendency for + one image to jumpy over the barrier. + + Args: + initial_species: The "reactant" species + + final_species: The "product" species + + large_step: The size of the DHS step when distance between the + images is larger than switch_thresh (Angstrom) + + small_step: The size of the DHS step when distance between the + images is smaller than swtich_thresh (Angstrom) + + switch_thresh: When distance between the two images is less than + this cutoff, smaller DHS extrapolation steps are taken + + conv_tol: Convergence tolerance for the distance-constrained + optimiser + + Keyword Args: + + maxiter: Maximum number of en/grad evaluations + + dist_tol: The distance tolerance at which DHS will + stop, values less than 0.5 Angstrom are not + recommended. + + cineb_at_conv: Whether to run CI-NEB calculation from the end + points after the DHS is converged + """ + super().__init__(initial_species, final_species, **kwargs) + + # imgpair is only used for storing the points here + self.imgpair: DHSImagePair = DHSImagePair( + initial_species, final_species + ) + + # DHS needs to keep an extra reference method and n_cores + self._method: Optional[Method] = None + self._n_cores: Optional[int] = None + + self._large_step = Distance(abs(large_step), "ang") + self._small_step = Distance(abs(small_step), "ang") + self._sw_thresh = Distance(abs(switch_thresh), "ang") + assert self._small_step < self._large_step + self._conv_tol = conv_tol + + self._step_size: Optional[Distance] = None + if self._large_step > self.imgpair.dist: + logger.warning( + f"Step size ({self._large_step:.3f} Å) for {self._name}" + f" is larger than the starting Euclidean distance between" + f" images ({self.imgpair.dist:.3f} Å). This calculation" + f" will likely run into errors." + ) + + # NOTE: In DHS the micro-iterations are done separately in + # an optimiser, so keep track with local variable + self._current_microiters: int = 0 + + def _initialise_run(self) -> None: + """ + Initialise energies/gradients for the first DHS macro-iteration + """ + self.imgpair.update_both_img_engrad() + return None + + def _step(self) -> None: + """ + A DHS step consists of a macro-iteration step, where a step along + the linear path between two images is taken, and several micro-iteration + steps in the distance-constrained optimiser, to return to the MEP + """ + assert self._method is not None, "Must have a set method" + assert self.imgpair.left_coords.e and self.imgpair.right_coords.e + + if self.imgpair.dist > self._sw_thresh: + self._step_size = self._large_step + else: + self._step_size = self._small_step + opt_trust = min(self._step_size, Distance(0.1, "ang")) + + if self.imgpair.left_coords.e < self.imgpair.right_coords.e: + side = ImageSide.left + pivot = self.imgpair.right_coords + else: + side = ImageSide.right + pivot = self.imgpair.left_coords + + old_coords: Any = self.imgpair.get_coord_by_side(side) + old_coords = old_coords if old_coords.h is not None else None + # take a DHS step on the side with lower energy + new_coord = self._get_dhs_step(side) + + # calculate the number of remaining maxiter to feed into optimiser + curr_maxiter = self._maxiter - self._current_microiters + if curr_maxiter <= 0: + return None + + opt = DistanceConstrainedOptimiser( + maxiter=curr_maxiter, + conv_tol=self._conv_tol, + init_trust=opt_trust, + pivot_point=pivot, + old_coords_read_hess=old_coords, + ) + tmp_spc = self._species.copy() + tmp_spc.coordinates = new_coord + opt.run(tmp_spc, self._method, self._n_cores) + self._micro_iter = self._micro_iter + opt.iteration + + # not converged can only happen if exceeded maxiter of optimiser + if not opt.converged: + return None + + rms_g_tau = np.sqrt(np.mean(np.square(opt.tangent_grad))) + logger.info( + "Successful optimization after DHS step, final RMS of " + f"tangential gradient = {rms_g_tau:.6f} " + f"Ha/angstrom" + ) + + # put results back into imagepair + self.imgpair.put_coord_by_side(opt.final_coordinates, side) # type: ignore + opt.clean_up() + return None + + def _calculate( + self, method: "Method", n_cores: Optional[int] = None + ) -> None: + """ + Run the DHS calculation and CI-NEB if requested. + + Args: + method (Method): Method used for calculating energy/gradients + n_cores (int): Number of cores to use for calculation + """ + self._method = method + self._n_cores = n_cores + super()._calculate(method, n_cores) + + @property + def _macro_iter(self): + """Total number of DHS steps taken so far""" + # ImagePair only stores the converged coordinates, which + # is equal to the number of macro-iterations (DHS steps) + return self.imgpair.total_iters + + @property + def _micro_iter(self) -> int: + """Total number of optimiser steps in DHS""" + return self._current_microiters + + @_micro_iter.setter + def _micro_iter(self, value: int): + """ + For DHS the number of microiters has to be manually + set + + Args: + value (int): + """ + self._current_microiters = int(value) + + def _get_dhs_step(self, side: ImageSide) -> CartesianCoordinates: + """ + Take a DHS step, on the side requested, along the distance + vector between the two images, and return the new coordinates + after taking the step + + Args: + side (ImageSide): left or right + + Returns: + (CartesianCoordinates): New predicted coordinates for that side + """ + assert self._step_size is not None + # take a DHS step of the size given + dhs_step = self.imgpair.get_dhs_step_by_side(side, self._step_size) + + old_coord = self.imgpair.get_coord_by_side(side) + new_coord = old_coord + dhs_step + + logger.info( + f"DHS step on {side} image: taking a step of" + f" size {np.linalg.norm(dhs_step):.4f} Å" + ) + return new_coord + + +class DHSGS(DHS): + """ + Dewar-Healy-Stewart method, augmented with Growing String (GS) + method. The DHS step (stepping along the linear interpolated + path between the two images) is mixed with a GS step (linear + interpolation along last and current position of one image) + in a fixed ratio. + + Proposed by J. Kilmes, D. R. Bowler, A. Michaelides, + J. Phys.: Condens. Matter, 2010, 22(7), 074203 + """ + + def __init__(self, *args, gs_mix: float = 0.5, **kwargs): + """ + Arguments and other keyword arguments follow DHS, please + see :py:meth:`DHS ` + + Keyword Args: + gs_mix (float): Represents the percentage of mixing of the + Growing String step with the DHS step. 0.3 + means 0.3 * GS_step + (1-0.3) * DHS_step + It is not recommended to set this higher + than 0.5 + """ + super().__init__(*args, **kwargs) + + self._gs_mix = float(gs_mix) + assert 0.0 < self._gs_mix < 1.0, "Mixing factor must be 0 < fac < 1" + + def _get_dhs_step(self, side: ImageSide) -> CartesianCoordinates: + """ + Take a mixed DHS and GS step (interpolates between the two + vectors) in the given ratio, and then return the new + coordinates after taking the step + + Args: + side (ImageSide): + + Returns: + (CartesianCoordinates): New predicted coordinates for that side + """ + assert self.imgpair is not None, "Must have an image pair" + assert self._step_size is not None + + dhs_step = self.imgpair.get_dhs_step_by_side(side, self._step_size) + gs_step = self.imgpair.get_last_step_by_side(side) + + if gs_step is None: + gs_step = np.zeros_like(dhs_step) + # hack to ensure the first step is 100% DHS (as GS is not possible) + dhs_step = dhs_step / (1 - self._gs_mix) + else: + # rescale GS step as well so that one vector doesn't dominate + gs_step *= np.linalg.norm(dhs_step) / np.linalg.norm(gs_step) + + old_coord = self.imgpair.get_coord_by_side(side) + new_coord = ( + old_coord + (1 - self._gs_mix) * dhs_step + self._gs_mix * gs_step + ) + # step size is variable due to adding GS component + step_size = np.linalg.norm(new_coord - old_coord) + logger.info( + f"DHS-GS step on {side} image: taking a step " + f"of size {step_size:.4f}" + ) + + return new_coord diff --git a/autodE/source/autode/bracket/ieip.py b/autodE/source/autode/bracket/ieip.py new file mode 100644 index 0000000000000000000000000000000000000000..27d334ff532f2c1a41db3f85f6ef6cd67a8dcb26 --- /dev/null +++ b/autodE/source/autode/bracket/ieip.py @@ -0,0 +1,601 @@ +""" +Improved Elastic Image Pair method for finding transition states. + +References: + +[1] Y. Liu, H. Qi, M. Lei, J. Chem. Theory Comput., 2023, 19, 2410-2417 +""" +from typing import Union, Optional, Tuple, List, TYPE_CHECKING +import numpy as np +from autode.methods import get_lmethod +from autode.bracket.base import BaseBracketMethod +from autode.bracket.dhs import TruncatedTaylor +from autode.neb import NEB +from autode.path.interpolation import CubicPathSpline +from autode.bracket.imagepair import EuclideanImagePair +from autode.opt.coordinates import CartesianCoordinates +from autode.values import Distance, GradientRMS, PotentialEnergy +from autode.utils import ProcessPool +from autode.log import logger + +if TYPE_CHECKING: + from autode.species.species import Species + from autode.wrappers.methods import Method + +_interp_image_density = 1.0 # per Angstrom for initial interpolation + + +def _calculate_low_sp_energy_for_species( + species: "Species", method: "Method", n_cores: int +) -> "PotentialEnergy": + """ + Convenience function to calculate the energy for a given species + + Args: + species (Species): The species object + method (Method): The method (low_sp keywords will be used) + n_cores (int): The number of cores + + Returns: + (PotentialEnergy): The single point energy of the species + """ + from autode import Calculation + + sp_calc = Calculation( + name=f"{species.name}_sp", + molecule=species, + method=method, + keywords=method.keywords.low_sp, # NOTE: We use low_sp + n_cores=n_cores, + ) + + sp_calc.run() + sp_calc.clean_up(force=True, everything=True) + + assert species.energy is not None + return species.energy + + +def _parallel_calc_energies( + points: List["Species"], method: "Method", n_cores: int +) -> List["PotentialEnergy"]: + """ + Calculate the single point energies on a list of species with + parallel runs + + Args: + points (list[Species]): A list of the species + method (Method): The method (low_sp keywords will be used) + n_cores (int): Total number of cores for all calculations + + Returns: + (list[PotentialEnergy]): List of energies in order + """ + n_cores_per_pp = max(n_cores // len(points), 1) + n_procs = min(n_cores, len(points)) + + with ProcessPool(max_workers=n_procs) as pool: + jobs = [ + pool.submit( + _calculate_low_sp_energy_for_species, + species=point, + method=method, + n_cores=n_cores_per_pp, + ) + for point in points + ] + + energies = [job.result() for job in jobs] + + return energies + + +class ElasticImagePair(EuclideanImagePair): + """ + This image-pair used for the Elastic Image Pair calculation. The + geometries after every macro-iteration are stored + """ + + @property + def last_left_step_size(self) -> float: + """The last step size on the left image""" + return np.linalg.norm(self.left_coords - self._left_history[-2]) + + @property + def last_right_step_size(self): + """The last step size on the right image""" + return np.linalg.norm(self.right_coords - self._right_history[-2]) + + @property + def ts_guess(self) -> Optional["Species"]: + """ + Obtain the TS guess from the i-EIP image pair. The midpoint + between the two converged images is considered the TS guess + + Returns: + (Species|None): The ts guess species, if images are available + """ + # account for initial redistribution + if self.total_iters <= 2: + return None + + tmp_spc = self._left_image.new_species(name="peak") + midpt_coords = np.array(self.left_coords + self.right_coords) / 2 + tmp_spc.coordinates = midpt_coords + return tmp_spc + + @property + def perp_rms_gs(self) -> Tuple[GradientRMS, GradientRMS]: + """ + The RMS norms of perpendicular gradient component for left + and right image, in order. The parallel component against + the distance vector is projected out. + + Returns: + (tuple[GradientRMS, GradientRMS]): + """ + perp_gradients = [] + d_hat = self.dist_vec / np.linalg.norm(self.dist_vec) + for coord in [self.left_coords, self.right_coords]: + parall_g = d_hat * np.dot(d_hat, coord.g) + perp_g = coord.g - parall_g + rms_perp_g = np.sqrt(np.mean(np.square(perp_g))) + perp_gradients.append(GradientRMS(rms_perp_g)) + + return perp_gradients[0], perp_gradients[1] + + def redistribute_imagepair( + self, + ll_neb_interp: bool = True, + interp_fraction: float = 1 / 4, + ): + """ + Redistribute the image pair by running a NEB calculation at lmethod or + use only IDPP interpolation, and then fitting a cubic spline on energy + calculated by the method (low_sp keywords). It generates the image pair + on both sides of the peak on the fitted spline, with a distance of + interp_fraction * total path distance on either side. + + Args: + ll_neb_interp (bool): Whether to optimise the interpolated path with + NEB at lmethod for the interpolation. + + interp_fraction (float): Fraction of total interpolated path distance + that will be used to generate the image pair + on either side of the interpolated TS + """ + # Use at least 5 images for interpolation + n_images = int(_interp_image_density * self.dist - 1) + n_images = max(n_images, 5 + 2) + + interp = NEB.from_end_points( + self._left_image.copy(), self._right_image.copy(), n_images + ) + if ll_neb_interp: + interp.calculate(method=get_lmethod(), n_cores=self._n_cores) + + # Only calc intermediate images, initial and final already have energies + path_points = interp.images[1:-1] + # Get energies + assert self.left_coords.e and self.right_coords.e + assert self._method is not None and self._n_cores is not None + path_energies = _parallel_calc_energies( + path_points, method=self._method, n_cores=self._n_cores + ) + energies = [self.left_coords.e] + path_energies + [self.right_coords.e] + logger.info( + f"Fitting parametric spline on {len(interp.images)} points" + ) + + # NOTE: Here we are fitting a parametric spline, with the parameter + # being the path length along approx. rxn coordinate and target being all + # coordinates *and* energy at the points + path_spline = CubicPathSpline.from_species_list(interp.images) + path_spline.fit_energies(energies) + peak_x = path_spline.energy_peak() + if peak_x is None: + raise RuntimeError( + "The fitted spline does not have a peak! Unable to proceed" + ) + # Check the peak is not at the beginning or end + assert 0.01 < peak_x < 0.99 + # convert to integrated arc lengths + peak_pos = path_spline.path_integral(0, peak_x) + path_length = path_spline.path_integral(0, 1) + + # Generate new coordinates a fraction (default 1/4) of total distance + # on each side of the peak (interpolated TS) + left_span = peak_pos - interp_fraction * path_length + if left_span <= 0.01: + l_point = 0.0 + else: + l_point = path_spline.integrate_upto_length( + span=left_span, + ) + r_point = path_spline.integrate_upto_length( + span=peak_pos + interp_fraction * path_length, + ) + if r_point > 1: + r_point = 1 + self.left_coords = CartesianCoordinates(path_spline.coords_at(l_point)) + self.right_coords = CartesianCoordinates( + path_spline.coords_at(r_point) + ) + return None + + +class IEIPMicroIters: + """ + Class to carry out the micro-iterations for the i-EIP + method + """ + + def __init__( + self, + left_coords: CartesianCoordinates, + right_coords: CartesianCoordinates, + micro_step_size: Union[Distance, float], + target_dist: Union[Distance, float], + ): + # generate the Taylor expansion surface from gradient and hessian + assert left_coords.g is not None and left_coords.h is not None + assert right_coords.g is not None and right_coords.h is not None + self._left_taylor_pes = TruncatedTaylor( + left_coords, left_coords.g, left_coords.h + ) + self._right_taylor_pes = TruncatedTaylor( + right_coords, right_coords.g, right_coords.h + ) + self._micro_step = float(Distance(micro_step_size, "ang")) + self._target_dist = float(Distance(target_dist, "ang")) + self.n_micro_iters = 0 # counter + self.left_coords = left_coords.copy() + self.right_coords = right_coords.copy() + # keep this in memory to calculate how much the coords have moved + self._start_left_coords = left_coords + self._start_right_coords = right_coords + + def update_both_img_engrad(self) -> None: + """ + Update the energy and gradient from the Taylor surface + """ + self.left_coords.e = PotentialEnergy( + self._left_taylor_pes.value(self.left_coords) + ) + self.left_coords.g = self._left_taylor_pes.gradient(self.left_coords) + self.right_coords.e = PotentialEnergy( + self._right_taylor_pes.value(self.right_coords) + ) + self.right_coords.g = self._right_taylor_pes.gradient( + self.right_coords + ) + return None + + @property + def _n_hat(self): + dist_vec = np.array(self.left_coords - self.right_coords) + return dist_vec / np.linalg.norm(dist_vec) + + def _get_perpendicular_micro_steps(self) -> List[np.ndarray]: + """ + Obtain the perpendicular displacement for one i-EIP micro-iteration, + minimises the energy in the direction perpendicular to the distance + vector connecting the image pair + + Returns: + (list[np.ndarray]): A list of steps for left and right + image, in order + """ + assert self.left_coords.g is not None + assert self.right_coords.g is not None + steps = [] + for coord in [self.left_coords, self.right_coords]: + force = -coord.g # type: ignore + force_parall = self._n_hat * np.dot(force, self._n_hat) + force_perp = force - force_parall + if np.linalg.norm(force_perp) > self._micro_step: + delta_x_perp = force_perp / np.linalg.norm(force_perp) + delta_x_perp *= self._micro_step + else: + delta_x_perp = force_perp + steps.append(delta_x_perp) + + return steps + + def _get_energy_micro_steps(self) -> List[np.ndarray]: + """ + Obtain the energy based displacement term for one i-EIP + micro-iteration. This term minimises the energy difference + between the two images + + Returns: + (list[np.ndarray]): A list of steps for the left and right + image, in order + """ + # NOTE: The sign is flipped here, because n_hat is + # defined in the opposite direction i.e. left - right + assert self.left_coords.e and self.right_coords.e + dist = np.linalg.norm(self.left_coords - self.right_coords) + f_de = (self.left_coords.e - self.right_coords.e) / float(dist) + f_de = self._n_hat * float(f_de) + if np.linalg.norm(f_de) > self._micro_step: + delta_x_e = f_de / np.linalg.norm(f_de) + delta_x_e *= self._micro_step + else: + delta_x_e = f_de + return [delta_x_e, delta_x_e] + + def _get_distance_micro_steps(self): + """ + Obtain the displacement term that controls the distance between + the two images. This term moves the images so that their distance + can be closer to the target distance in the current macro-iteration + + Returns: + (list[np.ndarray]): A list of steps for the left and right + images, in order + """ + # NOTE: The factor k that appears in eqn.(1) of the i-EIP paper + # has been absorbed into the term in this function (i.e. the function + # returns the displacements with the proper sign) + dist_vec = np.array(self.left_coords - self.right_coords) + dist = np.linalg.norm(dist_vec) + f_l = -dist_vec * 2 * (dist - self._target_dist) / dist + if np.linalg.norm(f_l) > self._micro_step: + delta_x_l = f_l / np.linalg.norm(f_l) + delta_x_l *= self._micro_step + else: + delta_x_l = f_l + return [delta_x_l, -delta_x_l] + + def take_micro_step(self) -> None: + """ + Take a single i-EIP micro-iteration step (which is a sum of the + perpendicular, energy and distance terms) + """ + perp_steps = self._get_perpendicular_micro_steps() + energy_steps = self._get_energy_micro_steps() + dist_steps = self._get_distance_micro_steps() + + # sum the micro-iteration step components + left_step = perp_steps[0] + energy_steps[0] + dist_steps[0] + right_step = perp_steps[1] + energy_steps[1] + dist_steps[1] + + # scale the steps within the microiter step size + if np.linalg.norm(left_step) > self._micro_step: + left_step *= self._micro_step / np.linalg.norm(left_step) + if np.linalg.norm(right_step) > self._micro_step: + right_step *= self._micro_step / np.linalg.norm(right_step) + + self.left_coords = self.left_coords + left_step + self.right_coords = self.right_coords + right_step + self.n_micro_iters += 1 + return None + + @property + def max_displacement(self) -> float: + left_displ = np.linalg.norm(self.left_coords - self._start_left_coords) + right_displ = np.linalg.norm( + self.right_coords - self._start_right_coords + ) + return max(left_displ, right_displ) + + +class IEIP(BaseBracketMethod): + """ + Improved Elastic Image Pair Method (i-EIP). It performs an initial + interpolation followed by spline fitting to redistribute the image + pair close to the interpolated TS. Then, micro-iterations are performed + to move the images closer while maintaining the distance + """ + + def __init__( + self, + initial_species: "Species", + final_species: "Species", + micro_step_size: Union[Distance, float] = Distance(1.5e-5, "ang"), + max_micro_per_macro: int = 2000, + max_macro_step: Union[Distance, float] = Distance(0.15, "ang"), + use_ll_neb_interp: bool = True, + interp_fraction: float = 1 / 4, + dist_tol: Union[Distance, float] = Distance(0.3, "ang"), + gtol: Union[GradientRMS, float] = GradientRMS(0.02, "Ha/ang"), + maxiter: int = 200, + **kwargs, + ): + """ + Initialise an i-EIP calculation from the initial (reactant) and + final (product) species. Every macro-iteration consists of two + gradient evaluations on both images, therefore, the total number + of gradient evaluations would be 2 * maxiter. Increase the + interp_fraction argument to start the calculation closer to the + reactant and product, and move ahead less with the initial + interpolation. + + Args: + initial_species: The "reactant" species + + final_species: The "product" species + + micro_step_size: The step size for every micro-iteration + + max_micro_per_macro: The maximum number of micro-iterations + per macro-iteration + + max_macro_step: The maximum step size for one macro-iteration + + use_ll_neb_interp: Whether to use lmethod NEB for the + initial interpolation instead of only IDPP + interpolation + + interp_fraction: Generate image pair on both sides of the + interpolated peak with this fraction of the + total interpolated path length (default 1/4) + + dist_tol: The Euclidean distance tolerance (between images) + for convergence + + gtol: The tolerance for perpendicular gradient RMS norm + + maxiter: For i-EIP maxiter is the maximum number of macro-iterations + + """ + assert ( + "cineb_at_conv" not in kwargs.keys() + ), "CI-NEB refinement is not available for i-EIP method!" + + super().__init__( + initial_species, + final_species, + gtol=gtol, + dist_tol=dist_tol, + maxiter=maxiter, + **kwargs, + ) + + self.imgpair: ElasticImagePair = ElasticImagePair( + initial_species, final_species + ) + self._micro_step_size = Distance(micro_step_size, "ang") + assert self._micro_step_size > 0 + self._max_micro_per = abs(int(max_micro_per_macro)) + self._max_macro_step = Distance(max_macro_step, "ang") + assert self._max_macro_step > 0 + self._ll_neb_interp = bool(use_ll_neb_interp) + self._interp_frac = float(interp_fraction) + assert 0 < interp_fraction < 1 + + # NOTE: In EIP the microiters are done separately in a throwaway + # imagepair object, so a variable is required to keep track + self._current_microiters: int = 0 + + self._target_dist: Optional[float] = None + self._target_rms_g: Optional[float] = None + + @property + def _micro_iter(self) -> int: + """ + Total number of micro-iterations. For i-EIP each micro-iteration + is on both of the images simultaneously + """ + return self._current_microiters + + @_micro_iter.setter + def _micro_iter(self, value): + """Set the total number of micro-iterations""" + self._current_microiters = int(value) + + @property + def _macro_iter(self) -> int: + """Total number of macro-iterations taken""" + # minus 1 due to first redistribution + return int(self.imgpair.total_iters / 2) - 1 + + @property + def converged(self) -> bool: + """Is the i-EIP method converged""" + # NOTE: Original publication recommends also checking overlap + # of image-pair mode with Hessian eigenvalue for convergence, but + # Hessian is expensive, so we use simpler check + return self.imgpair.dist < self._dist_tol and all( + rms_grad <= self._gtol for rms_grad in self.imgpair.perp_rms_gs + ) + + @property + def _exceeded_maximum_iteration(self) -> bool: + """Whether it has exceeded the number of maximum iterations""" + if self._macro_iter >= self._maxiter: + logger.error( + f"Reached the maximum number of macro-iterations " + f"*{self._maxiter}*" + ) + return True + else: + return False + + def _initialise_run(self) -> None: + """ + Initialise the i-EIP calculation by redistributing the + image pair and then estimating a low level hessian + """ + self.imgpair.update_both_img_engrad() + self.imgpair.redistribute_imagepair( + self._ll_neb_interp, self._interp_frac + ) + self.imgpair.update_both_img_engrad() + self.imgpair.update_both_img_hessian_by_calc() + self._target_dist = self.imgpair.dist + self._target_rms_g = ( + min(max(self.imgpair.dist / self._dist_tol, 1), 2) * self._gtol + ) + return None + + def _step(self) -> None: + """ + Take one EIP macro-iteration step and store the new coordinates + in history and update the energies and gradients + """ + self._update_target_distance_and_force() + + # Turn off logging for micro-iterations + logger.disabled = True + assert self._target_dist is not None + micro_imgpair = IEIPMicroIters( + left_coords=self.imgpair.left_coords, + right_coords=self.imgpair.right_coords, + micro_step_size=self._micro_step_size, + target_dist=self._target_dist, + ) + + while not ( + micro_imgpair.n_micro_iters >= self._max_micro_per + or micro_imgpair.max_displacement > self._max_macro_step + ): + micro_imgpair.update_both_img_engrad() + micro_imgpair.take_micro_step() + self._micro_iter += 1 + logger.disabled = False + self.imgpair.left_coords = micro_imgpair.left_coords + self.imgpair.right_coords = micro_imgpair.right_coords + self.imgpair.update_both_img_engrad() + self.imgpair.update_both_img_hessian_by_formula() + + logger.info( + f"Completed one i-EIP macro-iteration with " + f"{micro_imgpair.n_micro_iters} micro-iterations; maximum " + f"image displacement = {micro_imgpair.max_displacement:.3f}.\n" + f"Left image step: {self.imgpair.last_left_step_size:.3f}, " + f"Right image step: {self.imgpair.last_right_step_size:.3f}" + ) + return None + + def _update_target_distance_and_force(self): + """ + Update the target distance tolerance and the RMS gradients + for the current macro-iteration + """ + # only update if target RMS force and distance has been reached + if not all( + rms_grad <= self._target_rms_g + for rms_grad in self.imgpair.perp_rms_gs + ): + return None + + # NOTE: target distance near the end of optimisation + # must be slighly lower than the set dist_tol, otherwise + # it will never converge (as it won't go below dist_tol) + self._target_dist = max( + 0.9 * self.imgpair.dist, + self._dist_tol - 0.015, + ) + + self._target_rms_g = ( + min(max(self.imgpair.dist / self._dist_tol, 1), 2) * self._gtol + ) + + logger.info( + f"Updating target distance to {self._target_dist:.3f} Å" + f" and updating target RMS gradient to " + f"{self._target_rms_g:.3f} Ha/Å" + ) + return None diff --git a/autodE/source/autode/bracket/imagepair.py b/autodE/source/autode/bracket/imagepair.py new file mode 100644 index 0000000000000000000000000000000000000000..8023edcb0a339bbb76e54769df9f472c1a55a537 --- /dev/null +++ b/autodE/source/autode/bracket/imagepair.py @@ -0,0 +1,628 @@ +""" +Base classes for implementing all bracketing methods +that require a pair of images +""" +import itertools +import numpy as np +from abc import ABC, abstractmethod +from typing import Optional, Tuple, TYPE_CHECKING, Union, Iterator +from enum import Enum + +from autode.values import Distance, PotentialEnergy, Gradient +from autode.geom import get_rot_mat_kabsch +from autode.methods import get_lmethod +from autode.neb import CINEB +from autode.opt.coordinates import CartesianCoordinates +from autode.opt.optimisers.hessian_update import BofillUpdate +from autode.opt.optimisers.utils import Polynomial2PointFit +from autode.opt.optimisers.base import OptimiserHistory, print_geometries_from +from autode.plotting import plot_bracket_method_energy_profile +from autode.utils import work_in_tmp_dir, ProcessPool +from autode.log import logger + +if TYPE_CHECKING: + from autode.species import Species + from autode.wrappers.methods import Method + from autode.hessians import Hessian + + +def _calculate_engrad_for_species( + species: "Species", + method: "Method", + n_cores: int, +) -> Tuple[PotentialEnergy, Gradient]: + """ + Convenience function for calculating the energy/gradient + for a molecule; removes all input and output files after + the calculation is finished + + Returns: + (tuple[PotentialEnergy, Gradient]): Energy and gradient as tuple + """ + from autode.calculations import Calculation + + engrad_calc = Calculation( + name=f"{species.name}_engrad", + molecule=species, + method=method, + keywords=method.keywords.grad, + n_cores=n_cores, + ) + engrad_calc.run() + engrad_calc.clean_up(force=True, everything=True) + assert species.energy and species.gradient is not None, "Calc must be ok" + + return species.energy, species.gradient + + +@work_in_tmp_dir() +def _calculate_hessian_for_species( + species: "Species", + method: "Method", + n_cores: int, +) -> "Hessian": + """ + Convenience function for calculating the Hessian for a + molecule; removes all input and output files after + the calculation is finished + + Returns: + (Hessian): Hessian matrix + """ + from autode.calculations import Calculation + + species = species.new_species() + + hess_calc = Calculation( + name=f"{species.name}_hess", + molecule=species, + method=method, + keywords=method.keywords.hess, + n_cores=n_cores, + ) + hess_calc.run() + hess_calc.clean_up(force=True, everything=True) + assert species.hessian is not None, "Calc must be ok" + + return species.hessian + + +class BaseImagePair(ABC): + """ + Base class for a pair of images (e.g., reactant and product) of + the same species. The images are called 'left' and 'right' to + distinguish them, but there is no requirement for one to be + reactant or product. Calculations can be performed on both sides + parallely + """ + + def __init__( + self, + left_image: "Species", + right_image: "Species", + ): + """ + Initialize the image pair, does not set methods/n_cores + + Args: + left_image: One molecule of the pair + right_image: Another molecule of the pair + """ + from autode.species.species import Species + + assert isinstance(left_image, Species) + assert isinstance(right_image, Species) + self._left_image = left_image.new_species(name="left_image") + self._right_image = right_image.new_species(name="right_image") + self._sanity_check() + self._align_species() + + # for calculation + self._method = None + self._hess_method = None + self._n_cores = None + self._hessian_update_types = [BofillUpdate] + + self._left_history = OptimiserHistory() + self._right_history = OptimiserHistory() + # push the first coordinates into history + self.left_coords = CartesianCoordinates(self._left_image.coordinates) + self.right_coords = CartesianCoordinates(self._right_image.coordinates) + + def _sanity_check(self) -> None: + """ + Check if the two supplied images have the same solvent, + charge, multiplicity and the same atoms in the same order + """ + + if self._left_image.n_atoms != self._right_image.n_atoms: + raise ValueError( + "The initial_species and final_species must " + "have the same number of atoms!" + ) + + if ( + self._left_image.charge != self._right_image.charge + or self._left_image.mult != self._right_image.mult + or self._left_image.solvent != self._right_image.solvent + ): + raise ValueError( + "Charge/multiplicity/solvent of initial_species " + "and final_species supplied are not the same" + ) + + for idx in range(len(self._left_image.atoms)): + if ( + self._left_image.atoms[idx].label + != self._right_image.atoms[idx].label + ): + raise ValueError( + "The order of atoms in initial_species " + "and final_species must be the same. The " + f"atom at position {idx} is different in" + "the two species" + ) + + return None + + def _align_species(self) -> None: + """ + Translates both molecules to origin and then performs + a Kabsch rotation to orient the molecules as close as + possible against each other + """ + # first translate the molecules to the origin + logger.info( + "Translating initial_species (reactant) " + "and final_species (product) to origin" + ) + p_mat = self._left_image.coordinates.copy() + p_mat -= np.average(p_mat, axis=0) + self._left_image.coordinates = p_mat + + q_mat = self._right_image.coordinates.copy() + q_mat -= np.average(q_mat, axis=0) + self._right_image.coordinates = q_mat + + logger.info( + "Rotating initial_species (reactant) " + "to align with final_species (product) " + "as much as possible" + ) + rot_mat = get_rot_mat_kabsch(p_mat, q_mat) + rotated_p_mat = np.dot(rot_mat, p_mat.T).T + self._left_image.coordinates = rotated_p_mat + + def initialise_trj( + self, + left_history_name: str = "left_history_save.zip", + right_history_name: str = "right_history_save.zip", + ) -> None: + """ + Initialise the trajectory save files (history of coordinates on + left and right images) + + Args: + left_history_name: Name of savefile for left history + right_history_name: Name of savefile for right history + """ + self._left_history.open(left_history_name) + self._right_history.open(right_history_name) + + def close_trj(self): + """ + Put all coordinates in memory onto disk in the trajectory + save files, and close the trajectories + """ + self._left_history.close() + self._right_history.close() + + def set_method_and_n_cores( + self, + method: "Method", + n_cores: int, + hess_method: Optional["Method"] = None, + ) -> None: + """ + Sets the methods for en/grad calculation, and the total + number of cores used for any calculation in this image pair. + Optionally, also set the method for hessian calculation; if + not set, the available lmethod will be used. + + Args: + method (Method): Method used for calculating energy/gradient + n_cores (int): Number of cores available + hess_method (Method|None): Method used for calculating + Hessian (optional) + """ + from autode.wrappers.methods import Method + + if not isinstance(method, Method): + raise TypeError( + f"The method needs to be of type autode." + f"wrappers.method.Method, But " + f"{type(method)} was supplied." + ) + self._method = method + + if hess_method is None: + hess_method = get_lmethod() + + if not isinstance(hess_method, Method): + raise TypeError( + f"The hessian method needs to be of type autode." + f"wrappers.method.Method, But {type(hess_method)}" + f"was supplied" + ) + self._hess_method = hess_method + + self._n_cores = int(n_cores) + return None + + @property + def n_atoms(self) -> int: + """Number of atoms""" + return self._left_image.n_atoms + + @property + def total_iters(self) -> int: + """Total number of iterations done on this image pair""" + return len(self._left_history) + len(self._right_history) - 2 + + @property + def left_coords(self) -> CartesianCoordinates: + """The coordinates of the left image""" + assert isinstance(self._left_history[-1], CartesianCoordinates) + return self._left_history[-1] + + @left_coords.setter + def left_coords(self, value: CartesianCoordinates): + """ + Sets the coordinates of the left image, also updates + the coordinates of the species + + Args: + value (CartesianCoordinates|None): new set of coordinates + + Raises: + (TypeError): If input is not of type CartesianCoordinates + (ValueError): If input does not have correct shape + """ + if value.shape[0] != 3 * self.n_atoms: + raise ValueError(f"Must have {self.n_atoms * 3} entries") + + if isinstance(value, CartesianCoordinates): + self._left_history.add(value.copy()) + else: + raise TypeError + + self._left_image.coordinates = self.left_coords + + @property + def right_coords(self) -> CartesianCoordinates: + """The coordinates of the right image""" + assert isinstance(self._right_history[-1], CartesianCoordinates) + return self._right_history[-1] + + @right_coords.setter + def right_coords(self, value: CartesianCoordinates): + """ + Sets the coordinates of the right image, also updates + the coordinates of the species + + Args: + value (CartesianCoordinates|None): new set of coordinates + + Raises: + (TypeError): If input is not of type CartesianCoordinates + (ValueError): If input does not have correct shape + """ + if value.shape[0] != 3 * self.n_atoms: + raise ValueError(f"Must have {self.n_atoms * 3} entries") + + if isinstance(value, CartesianCoordinates): + self._right_history.add(value.copy()) + else: + raise TypeError + + self._right_image.coordinates = self.right_coords + + @property + @abstractmethod + def ts_guess(self) -> Optional["Species"]: + """TS guess species for this image-pair""" + + @property + @abstractmethod + def dist_vec(self) -> np.ndarray: + """Distance vector defined from left to right image""" + + @property + @abstractmethod + def dist(self) -> Distance: + """Distance defined between two images in the image-pair""" + + @property + @abstractmethod + def has_jumped_over_barrier(self) -> bool: + """Whether one image has jumped over the barrier on the other side""" + + def update_both_img_engrad(self): + """ + Update the energy/gradient for both images, with parallel processing + """ + assert self._method is not None + assert self._n_cores is not None + n_cores_per_pp = self._n_cores // 2 if self._n_cores > 1 else 1 + n_procs = 1 if self._n_cores < 2 else 2 + with ProcessPool(max_workers=n_procs) as pool: + jobs = [ + pool.submit( + _calculate_engrad_for_species, + species=img, + method=self._method, + n_cores=n_cores_per_pp, + ) + for img in [self._left_image, self._right_image] + ] + left_engrad, right_engrad = [job.result() for job in jobs] + + self.left_coords.e = left_engrad[0] + self.left_coords.update_g_from_cart_g(left_engrad[1]) + self.right_coords.e = right_engrad[0] + self.right_coords.update_g_from_cart_g(right_engrad[1]) + return None + + def update_both_img_hessian_by_calc(self): + """ + Update the molecular hessian of both images by calculation + """ + # TODO: refactor into ll_hessian code + assert self._hess_method is not None + assert self._n_cores is not None + n_cores_per_pp = self._n_cores // 2 if self._n_cores > 1 else 1 + n_procs = 1 if self._n_cores < 2 else 2 + with ProcessPool(max_workers=n_procs) as pool: + jobs = [ + pool.submit( + _calculate_hessian_for_species, + species=img, + method=self._hess_method, + n_cores=n_cores_per_pp, + ) + for img in [self._left_image, self._right_image] + ] + left_hess, right_hess = [job.result() for job in jobs] + + self.left_coords.update_h_from_cart_h(left_hess) + self.right_coords.update_h_from_cart_h(right_hess) + return None + + def update_both_img_hessian_by_formula(self): + """ + Update the molecular hessian for both images by update formula + """ + for history in [self._left_history, self._right_history]: + history.final.update_h_from_old_h( + history.penultimate, self._hessian_update_types + ) + + return None + + +class EuclideanImagePair(BaseImagePair, ABC): + """ + Image-pair that defines the distance between the images as + the Euclidean distance. It can also run CI-NEB calculation + from the final two points added to the image-pair, and + plot the energies of the total path + """ + + def __init__( + self, + left_image: "Species", + right_image: "Species", + ): + super().__init__(left_image=left_image, right_image=right_image) + + # for storing results from CINEB + self._cineb_coords: Optional[CartesianCoordinates] = None + + @property + def dist_vec(self) -> np.ndarray: + """ + Distance vector in cartesian coordinates, it is defined here to + go from right to left image (i.e. right -> left) + """ + return np.array( + self.left_coords.to("cart") - self.right_coords.to("cart") + ) + + @property + def dist(self) -> Distance: + """ + Euclidean distance between the images in image-pair + + Returns: + (Distance): Distance in Angstrom + """ + return Distance(np.linalg.norm(self.dist_vec), units="ang") + + @property + def has_jumped_over_barrier(self) -> bool: + """ + A quick test of whether the images are still separated by a barrier, + implemented via fitting a cubic polynomial along the linear path + connecting the two images and checking for a peak. This is only an + approximation. + """ + assert self.left_coords is not None and self.right_coords is not None + assert self.left_coords.e and self.right_coords.e + assert ( + self.left_coords.g is not None and self.right_coords.g is not None + ) + cubic_poly = Polynomial2PointFit.cubic_fit( + self.left_coords, self.right_coords + ) + + # NOTE: Interpolation seems reasonable upto ~1.2 Angstrom. If distance + # is larger, detecting peak is impossible without calculating energies + # so we assume there is a barrier between the images + if self.dist > Distance(1.2, "ang"): + return False + else: + return cubic_poly.get_extremum(0.0, 1.0, get_max=True) is None + + def run_cineb_from_end_points(self) -> None: + """ + Runs a CI-NEB calculation from the end-points of the image-pair + and then stores the coordinates of the peak point obtained + from the CI-NEB run + + Returns: + (CartesianCoordinates): Coordinates of the peak species obtained + from the CI-NEB run + """ + assert self._method is not None, "Methods must be set" + assert self._n_cores is not None, "Number of cores must be set" + + cineb = CINEB.from_end_points( + self._left_image, self._right_image, num=3 + ) + cineb.calculate(method=self._method, n_cores=self._n_cores) + + if not cineb.images.contains_peak: + logger.error("CI-NEB failed to find the peak") + return None + + peak = cineb.images[cineb.images.peak_idx] # type: ignore + ci_coords = CartesianCoordinates(peak.coordinates) + ci_coords.e = peak.energy + ci_coords.update_g_from_cart_g(peak.gradient) + + self._cineb_coords = ci_coords + return None + + @property + def _total_history(self) -> Iterator[CartesianCoordinates]: + """ + The total history of the image-pair, including any CI run + from the endpoints + """ + cineb_coords = [] + if self._cineb_coords is not None: + cineb_coords.append(self._cineb_coords) + return itertools.chain( + self._left_history, cineb_coords, reversed(self._right_history) + ) + + def print_geometries( + self, + init_trj_filename: str, + final_trj_filename: str, + total_trj_filename: str, + ) -> None: + """ + Write trajectories as *.xyz files, one for the initial species, + one for final species, and one for the whole trajectory, including + any CI-NEB run from the final end points + """ + if self.total_iters < 2: + logger.warning("Cannot write trajectory, not enough points") + return None + + print_geometries_from( + self._left_history, + species=self._left_image, + filename=init_trj_filename, + ) + print_geometries_from( + self._right_history, + species=self._right_image, + filename=final_trj_filename, + ) + print_geometries_from( + self._total_history, + species=self._left_image, + filename=total_trj_filename, + ) + + return None + + def plot_energies( + self, + filename: str, + distance_metric: str, + ) -> None: + """ + Plots the energies of the image-pair, including any CI-NEB + calculation done at the end. The distance metric argument + determines how the x-axis values are plotted and their + meaning (Described in more detail in BaseBracketMethod) + + Args: + filename (str): name of the plot file to save + distance_metric (str): "relative" or "from_start" or "index" + + See Also: + :py:meth:`BaseBracketMethod ` + """ + + class Metrics(Enum): + relative = 1 + from_start = 2 + index = 3 + + metric = Metrics[distance_metric] + + if self.total_iters < 2: + logger.warning("Cannot plot energies, not enough points") + return None + + all_energies = [coord.e for coord in self._total_history] + if any(en is None for en in all_energies): + logger.error( + "One or more coordinates do not have associated" + " energies, unable to produce energy plot!" + ) + return None + + num_left_points = len(self._left_history) + num_right_points = len(self._right_history) + first_point = self._left_history[0] + points: list = [] # list of tuples + + lowest_en = min(all_energies) # type: ignore + + last_coord = None + for idx, coord in enumerate(self._total_history): + en = coord.e - lowest_en # type: ignore + if metric == Metrics.relative: + if idx == 0: + x = 0 + else: + x = np.linalg.norm(coord - last_coord) + x += points[idx - 1][0] # add previous distance + elif metric == Metrics.from_start: + x = np.linalg.norm(coord - first_point) + else: # metric == Metrics.index: + x = idx + points.append((x, en)) + last_coord = coord + + left_points = points[:num_left_points] + if self._cineb_coords is not None: + cineb_point = points[num_left_points] + else: + cineb_point = None + right_points = points[-num_right_points:] + if distance_metric == "relative": + x_axis_title = "Change in Euclidean Distance (Å)" + elif distance_metric == "from_start": + x_axis_title = "Euclidean Distance from Reactant Structure (Å)" + else: + x_axis_title = "Point in Reaction Path" + + plot_bracket_method_energy_profile( + filename, left_points, cineb_point, right_points, x_axis_title + ) diff --git a/autodE/source/autode/calculations/__init__.py b/autodE/source/autode/calculations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..866fe439970d73c18def873aae69af7adceaf019 --- /dev/null +++ b/autodE/source/autode/calculations/__init__.py @@ -0,0 +1,5 @@ +from autode.calculations.calculation import Calculation +from autode.calculations.input import CalculationInput +from autode.calculations.output import CalculationOutput + +__all__ = ["Calculation", "CalculationInput", "CalculationOutput"] diff --git a/autodE/source/autode/calculations/calculation.py b/autodE/source/autode/calculations/calculation.py new file mode 100644 index 0000000000000000000000000000000000000000..d0625498b36136cfff575cdbf4bbb3eaf12c5b39 --- /dev/null +++ b/autodE/source/autode/calculations/calculation.py @@ -0,0 +1,328 @@ +import autode.wrappers.keywords as kws +import autode.exceptions as ex + +from copy import deepcopy +from typing import Optional, List, TYPE_CHECKING + +from autode.point_charges import PointCharge +from autode.log import logger +from autode.calculations.types import CalculationType +from autode.calculations.executors import ( + CalculationExecutor, + CalculationExecutorO, + CalculationExecutorG, + CalculationExecutorH, +) + +if TYPE_CHECKING: + from autode.species.species import Species + from autode.wrappers.methods import Method + from autode.wrappers.keywords import Keywords + from autode.calculations.input import CalculationInput + from autode.calculations.output import CalculationOutput + from autode.calculations.executors import CalculationExecutor + from autode.opt.optimisers.base import BaseOptimiser + +output_exts = ( + ".out", + ".hess", + ".xyz", + ".inp", + ".com", + ".log", + ".nw", + ".pc", + ".grad", +) + + +class Calculation: + def __init__( + self, + name: str, + molecule: "Species", + method: "Method", + keywords: "Keywords", + n_cores: int = 1, + point_charges: Optional[List[PointCharge]] = None, + ): + """ + Calculation e.g. single point energy evaluation on a molecule. This + will update the molecule inplace. For example, an optimisation will + alter molecule.atoms. + + ----------------------------------------------------------------------- + Arguments: + name: Name of the calculation. Will be modified with a method + suffix + + molecule: Molecule to be calculated. This may have a set of + associated cartesian or distance constraints + + method: Wrapped electronic structure method, or other e.g. + forcefield capable of calculating energies and gradients + + keywords: Keywords defining the type of calculation and e.g. what + basis set and functional to use. + + n_cores: Number of cores available (default: {1}) + + point_charges: List of float of point charges + """ + + self.name = name + self.n_cores = int(n_cores) + self.point_charges = point_charges + self._executor = self._executor_for(molecule, method, keywords) + + self._check() + + def _executor_for( + self, + molecule: "Species", + method: "Method", + keywords: "Keywords", + ) -> "CalculationExecutor": + """ + Return a calculation executor depending on the calculation modes + implemented in the wrapped method. For instance if the method does not + implement any optimisation then use an executor that uses the in built + autodE optimisers (in autode/opt/). Equally if the method does not + implement way of calculating Hessians then use a numerical evaluation + of the Hessian + """ + _type = CalculationExecutor # base type, implements all calc types + + if _are_opt(keywords) and not method.implements(CalculationType.opt): + _type = CalculationExecutorO + + if _are_grad(keywords) and not method.implements( + CalculationType.gradient + ): + _type = CalculationExecutorG + + if _are_hess(keywords) and not method.implements( + CalculationType.hessian + ): + _type = CalculationExecutorH + + return _type( + self.name, + molecule, + method, + keywords, + self.n_cores, + self.point_charges, + ) + + def run(self) -> None: + """Run the calculation using the EST method""" + logger.info(f"Running calculation: {self.name}") + + self._executor.run() + self._check_properties_exist() + self._add_to_comp_methods() + + return None + + def clean_up(self, force: bool = False, everything: bool = False) -> None: + """ + Clean up input and output files, if Config.keep_input_files is False + (and not force=True) + + ----------------------------------------------------------------------- + Keyword Arguments: + + force (bool): If True then override Config.keep_input_files + + everything (bool): Remove both input and output files + """ + return self._executor.clean_up(force, everything) + + def generate_input(self) -> None: + """Generate the input required for this calculation""" + + if not self.method.uses_external_io: + logger.warning( + "Calculation does not create an input file. No " + "input has been generated" + ) + else: + self._executor.generate_input() + + @property + def terminated_normally(self) -> bool: + """ + Determine if the calculation terminated without error + + ----------------------------------------------------------------------- + Returns: + (bool): Normal termination of the calculation? + """ + return self._executor.terminated_normally + + @property + def input(self) -> "CalculationInput": + """The input used to run this calculation""" + return self._executor.input + + @property + def output(self) -> "CalculationOutput": + """The output generated by this calculation""" + return self._executor.output + + def set_output_filename(self, filename: str) -> None: + """ + Set the output filename. If it exists then the properties of + the molecule this calculation was created with from will be + set + """ + self._executor.output.filename = filename + self._executor.set_properties() + self._check_properties_exist() + return None + + @property + def optimiser(self) -> "BaseOptimiser": + """The optimiser used to run this calculation""" + return self._executor.optimiser + + def copy(self) -> "Calculation": + return deepcopy(self) + + @property + def molecule(self) -> "Species": + return self._executor.molecule + + @molecule.setter + def molecule(self, value: "Species"): + self._executor.molecule = value + + @property + def keywords(self) -> "Keywords": + return self._executor.input.keywords + + @property + def method(self) -> "Method": + return self._executor.method + + def _check(self) -> None: + """ + Ensure the molecule has the required properties and raise exceptions + if they are not present. Also ensure that the method has the requsted + solvent available. + + ----------------------------------------------------------------------- + Raises: + (ValueError | autode.exceptions.CalculationException): + """ + from autode.species.species import Species + + assert isinstance(self.molecule, Species) + + if self.molecule.atoms is None or self.molecule.n_atoms == 0: + raise ex.NoInputError("Have no atoms. Can't form a calculation") + + if not self.molecule.has_valid_spin_state: + raise ex.CalculationException( + f"Cannot execute a calculation without a valid spin state: " + f"Spin multiplicity (2S+1) = {self.molecule.mult}" + ) + + return None + + def _add_to_comp_methods(self) -> None: + """Add the methods used in this calculation to the used methods list""" + from autode.log.methods import methods + + methods.add( + f"Calculations were performed using {self.method.name} v. " + f"{self.method.version_in(self._executor)} " + f"({self.method.doi_str})." + ) + + # Type of calculation ---- + if isinstance(self.input.keywords, kws.SinglePointKeywords): + string = "Single point " + + elif isinstance(self.input.keywords, kws.OptKeywords): + string = "Optimisation " + + else: + logger.warning( + "Not adding gradient or hessian to methods section " + "anticipating that they will be the same as opt" + ) + # and have been already added to the methods section + return + + # Level of theory ---- + string += ( + f"calculations performed at the " + f"{self.input.keywords.method_string} level" + ) + + basis = self.input.keywords.basis_set + if basis is not None: + string += ( + f" in combination with the {str(basis)} " + f"({basis.doi_str}) basis set" + ) + + if ( + self.molecule.solvent is not None + and self.molecule.solvent.is_implicit + ): + solv_type = self.method.implicit_solvation_type + assert solv_type is not None, "Must have an implicit solvent type" + doi = solv_type.doi_str if hasattr(solv_type, "doi_str") else "?" + + string += ( + f" and {solv_type.upper()} ({doi}) " + f"solvation, with parameters appropriate for " + f"{self.molecule.solvent}" + ) + + methods.add(f"{string}.\n") + return None + + def _check_properties_exist(self) -> None: + """ + Check that the requested properties, as defined by the type of keywords + that this calculation was requested with have been set. + + ----------------------------------------------------------------------- + Raises: + (CouldNotGetProperty): If the required property couldn't be found + """ + logger.info("Checking required properties exist") + + if not self.terminated_normally: + logger.error( + f"Calculation of {self.molecule} did not terminate " + f"normally" + ) + raise ex.CouldNotGetProperty() + + if self.molecule.energy is None: + raise ex.CouldNotGetProperty(name="energy") + + if _are_grad(self.keywords) and self.molecule.gradient is None: + raise ex.CouldNotGetProperty(name="gradient") + + if _are_hess(self.keywords) and self.molecule.hessian is None: + raise ex.CouldNotGetProperty(name="Hessian") + + return None + + +def _are_opt(keywords) -> bool: + return isinstance(keywords, kws.OptKeywords) + + +def _are_grad(keywords) -> bool: + return isinstance(keywords, kws.GradientKeywords) + + +def _are_hess(keywords) -> bool: + return isinstance(keywords, kws.HessianKeywords) diff --git a/autodE/source/autode/calculations/executors.py b/autodE/source/autode/calculations/executors.py new file mode 100644 index 0000000000000000000000000000000000000000..68ac1b328d4aaba25ee1f937cfc1c2c10da1f2c9 --- /dev/null +++ b/autodE/source/autode/calculations/executors.py @@ -0,0 +1,524 @@ +""" +A collection of calculation executors which can execute the correct set of +steps to run a calculation for a specific method, depending on what it +implements +""" +import os +import hashlib +import base64 +import autode.exceptions as ex +import autode.wrappers.keywords as kws + +from typing import Optional, List, Tuple, TYPE_CHECKING +from copy import deepcopy + +from autode.log import logger +from autode.config import Config +from autode.values import Distance +from autode.utils import no_exceptions, requires_output_to_exist +from autode.point_charges import PointCharge +from autode.opt.optimisers.base import NullOptimiser, BaseOptimiser +from autode.calculations.input import CalculationInput +from autode.calculations.output import ( + CalculationOutput, + BlankCalculationOutput, +) +from autode.values import PotentialEnergy, GradientRMS + +if TYPE_CHECKING: + from autode.species.species import Species + from autode.wrappers.methods import Method + from autode.wrappers.keywords import Keywords + from autode.opt.optimisers.base import NDOptimiser + + +class CalculationExecutor: + def __init__( + self, + name: str, + molecule: "Species", + method: "Method", + keywords: "Keywords", + n_cores: int = 1, + point_charges: Optional[List[PointCharge]] = None, + ): + # Calculation names that start with "-" can break EST methods + self.name = f"{_string_without_leading_hyphen(name)}_{method.name}" + + self.molecule = molecule + self.method = method + self.optimiser: BaseOptimiser = NullOptimiser() + self.n_cores = int(n_cores) + + self.input = CalculationInput( + keywords=keywords, + added_internals=_active_bonds(molecule), + point_charges=point_charges, + ) + self._external_output = CalculationOutput() + self._check() + + def _check(self) -> None: + """Check that the method has the required properties to run the calc""" + + if self.molecule.solvent is None or self.molecule.solvent.is_explicit: + return + + if getattr(self.molecule.solvent, self.method.name) is None: + err_str = ( + f"Could not find {self.molecule.solvent} for " + f"{self.method.name}. Available solvents for {self.method.name} " + f"are: {self.method.available_implicit_solvents}" + ) + + raise ex.SolventUnavailable(err_str) + + return None + + def run(self) -> None: + """Run/execute the calculation""" + + if self.method.uses_external_io: + self.generate_input() + self.output.filename = self.method.output_filename_for(self) + self._execute_external() + self.set_properties() + self.clean_up() + + else: + self.method.execute(self) + + return None + + def generate_input(self) -> None: + """Generate the required input file""" + logger.info(f"Generating input file(s) for {self.name}") + + # Can switch off uniqueness testing with e.g. + # export AUTODE_FIXUNIQUE=False used for testing + if os.getenv("AUTODE_FIXUNIQUE", True) != "False": + self._fix_unique() + + self.input.filename = self.method.input_filename_for(self) + + # Check that if the keyword is a autode.wrappers.keywords.Keyword then + # it has the required name in the method used for this calculation + for keyword in self.input.keywords: + if not isinstance(keyword, kws.Keyword): # allow string keywords + continue + + # Allow for the unambiguous setting of a keyword with only a name + if keyword.has_only_name: + # set e.g. keyword.orca = 'b3lyp' + setattr(keyword, self.method.name, keyword.name) + continue + + # For a keyword e.g. Keyword(name='pbe', orca='PBE') then the + # definition in this method is not obvious, so raise an exception + if getattr(keyword, self.method.name) is None: + err_str = ( + f"Keyword: {keyword} is not supported set " + f"{repr(keyword)}.{self.method.name} as a string" + ) + raise ex.UnsupportedCalculationInput(err_str) + + return self.method.generate_input_for(self) + + def _execute_external(self) -> None: + """ + Execute an external calculation i.e. one that saves a log file if it + has not been run, or if it did not finish with a normal termination + """ + logger.info(f"Running {self.input.filename} using {self.method.name}") + + if not self.input.exists: + raise ex.NoInputError("Input did not exist") + + if self.output.exists and self.terminated_normally: + logger.info("Calculation already terminated normally. Skipping") + return None + + if not self.method.is_available: + raise ex.MethodUnavailable(f"{self.method} was not available") + + self.output.clear() + self.method.execute(self) + + return None + + @requires_output_to_exist + def set_properties(self) -> None: + """Set the properties of a molecule from this calculation""" + keywords = self.input.keywords + + if isinstance(keywords, kws.OptKeywords): + self.optimiser = self.method.optimiser_from(self) + self.molecule.coordinates = self.method.coordinates_from(self) + + self.molecule.energy = self.method.energy_from(self) + + if isinstance(keywords, kws.GradientKeywords): + self.molecule.gradient = self.method.gradient_from(self) + else: # Try to set the gradient anyway + self._no_except_set_gradient() + + if isinstance(keywords, kws.HessianKeywords): + self.molecule.hessian = self.method.hessian_from(self) + else: # Try to set hessian anyway + self._no_except_set_hessian() + + try: + self.molecule.partial_charges = self.method.partial_charges_from( + self + ) + except (ValueError, IndexError, ex.AutodeException): + logger.warning("Failed to set partial charges") + + return None + + @no_exceptions + def _no_except_set_gradient(self) -> None: + self.molecule.gradient = self.method.gradient_from(self) + + @no_exceptions + def _no_except_set_hessian(self) -> None: + self.molecule.hessian = self.method.hessian_from(self) + + def clean_up(self, force: bool = False, everything: bool = False) -> None: + if not self.method.uses_external_io: # Then there are no i/o files + return None + + if Config.keep_input_files and not force: + logger.info("Keeping input files") + return None + + filenames = self.input.filenames + if everything: + filenames.append(self.output.filename) + filenames += [ + fn for fn in os.listdir() if fn.startswith(self.name) + ] + + logger.info(f"Deleting: {set(filenames)}") + + for filename in [fn for fn in set(filenames) if fn is not None]: + try: + os.remove(filename) + except FileNotFoundError: + logger.warning(f"Could not delete {filename} it did not exist") + + return None + + @property + def terminated_normally(self) -> bool: + """ + Determine if the calculation terminated without error + + ----------------------------------------------------------------------- + Returns: + (bool): Normal termination of the calculation? + """ + logger.info(f"Checking for {self.output.filename} normal termination") + + if self.method.uses_external_io and not self.output.exists: + logger.warning("Calculation did not generate any output") + return False + + return self.method.terminated_normally_in(self) + + @property + def output(self) -> "CalculationOutput": + """ + Calculation output. If the method does not use any external files + then a blank calculation output is returned + """ + + if self.method.uses_external_io: + return self._external_output + else: + return BlankCalculationOutput() + + @output.setter + def output(self, value: CalculationOutput): + """Set the value of the calculation output""" + assert isinstance(value, CalculationOutput) + + self._external_output = value + + def copy(self) -> "CalculationExecutor": + return deepcopy(self) + + def __str__(self): + """Create a unique string(/hash) of the calculation""" + string = ( + f"{self.name}{self.method.name}{repr(self.input.keywords)}" + f"{self.molecule}{self.method.implicit_solvation_type}" + f"{self.molecule.constraints}" + ) + + hasher = hashlib.sha1(string.encode()).digest() + return base64.urlsafe_b64encode(hasher).decode() + + def _fix_unique(self, register_name=".autode_calculations") -> None: + """ + If a calculation has already been run for this molecule then it + shouldn't be run again, unless the input keywords have changed, in + which case it should be run while retaining the previous data. This + function fixes this problem by checking .autode_calculations and adding + a number to the end of self.name if the calculation input is different + """ + + def append_register(): + with open(register_name, "a") as register_file: + print(self.name, str(self), file=register_file) + + def exists(): + return any(reg_name == self.name for reg_name in register.keys()) + + def is_identical(): + return any(reg_id == str(self) for reg_id in register.values()) + + # If there is no register yet in this folder then create it + if not os.path.exists(register_name): + logger.info("No calculations have been performed here yet") + append_register() + return None + + # Populate a register of calculation names and their unique identifiers + register = {} + for line in open(register_name, "r"): + if len(line.split()) == 2: # Expecting: name id + calc_name, identifier = line.split() + register[calc_name] = identifier + + if is_identical(): + logger.info("Calculation exists in registry") + return None + + # If this calculation doesn't yet appear in the register add it + if not exists(): + logger.info("This calculation has not yet been run") + append_register() + return None + + # If we're here then this calculation - with these input - has not yet + # been run. Therefore, add an integer to the calculation name until + # either the calculation has been run before and is the same or it's + # not been run + logger.info( + "Calculation with this name has been run before but " + "with different input" + ) + name, n = self.name, 0 + while True: + self.name = f"{name}{n}" + logger.info(f"New calculation name is: {self.name}") + + if is_identical(): + return None + + if not exists(): + append_register() + return None + + n += 1 + + +class _IndirectCalculationExecutor(CalculationExecutor): + """ + An 'indirect' executor is one that, given a calculation to perform, + calls the method multiple times and aggregates the results in some way. + Therefore, there is no direct calculation output. + """ + + @property + def output(self) -> "CalculationOutput": + return BlankCalculationOutput() + + @output.setter + def output(self, value: CalculationOutput): + raise ValueError("Cannot set the output of an indirect calculation") + + +class CalculationExecutorO(_IndirectCalculationExecutor): + """Calculation executor that uses autodE inbuilt optimisation""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.conv_tol = "normal" + self._fix_unique() + + def run(self) -> None: + """Run an optimisation with using default autodE optimisers""" + from autode.opt.optimisers.crfo import CRFOptimiser + from autode.opt.optimisers.prfo import PRFOptimiser + + if self._opt_trajectory_exists: + self.optimiser = CRFOptimiser.from_file(self._opt_trajectory_name) + self._set_properties_from_optimiser() + return None + + type_ = PRFOptimiser if self._calc_is_ts_opt else CRFOptimiser + + self.optimiser: "NDOptimiser" = type_( + init_alpha=self._step_size, + maxiter=self._max_opt_cycles, + conv_tol=self.conv_tol, + ) + method = self.method.copy() + method.keywords.grad = kws.GradientKeywords(self.input.keywords) + + self.optimiser.run( + species=self.molecule, + method=method, + n_cores=self.n_cores, + name=self._opt_trajectory_name, + ) + self.optimiser.print_geometries( + self._opt_trajectory_name[:-4] + if self._opt_trajectory_name.endswith(".zip") + else self._opt_trajectory_name + ) + + if self.molecule.n_atoms == 1: + return self._run_single_energy_evaluation() + + if self._calc_is_ts_opt: + # If this calculation is a transition state optimisation then a + # hessian on the final structure is required + self.molecule.calc_hessian( + method=self.method, n_cores=self.n_cores + ) + return None + + def _run_single_energy_evaluation(self) -> None: + """Run a single point energy evaluation, suitable for a single atom""" + from autode.calculations.calculation import Calculation + + calc = Calculation( + name=f"{self.molecule.name}_energy", + molecule=self.molecule, + method=self.method, + keywords=kws.SinglePointKeywords(self.input.keywords), + n_cores=self.n_cores, + ) + calc.run() + return None + + @property + def terminated_normally(self) -> bool: + """ + Using inbuilt optimisers raise exceptions if something goes wrong, so + this property is always true, provided the output exists + + ----------------------------------------------------------------------- + Returns: + (bool): Normal termination of the calculation? + """ + return self._opt_trajectory_exists or self.molecule.n_atoms == 1 + + def set_properties(self) -> None: + """ + Nothing needs to be set as the energy/gradient/Hessian of the + molecule are set within the optimiser + """ + return None + + @property + def _calc_is_ts_opt(self) -> bool: + """Does this calculation correspond to a transition state opt""" + return isinstance(self.input.keywords, kws.OptTSKeywords) + + @property + def _max_opt_cycles(self) -> int: + """Get the maximum num of optimisation cycles for this calculation""" + try: + return next( + int(kwd) + for kwd in self.input.keywords + if isinstance(kwd, kws.MaxOptCycles) + ) + except StopIteration: + return 50 + + @property + def _step_size(self) -> float: + return 0.05 if self._calc_is_ts_opt else 0.1 + + @property + def _opt_trajectory_name(self) -> str: + return f"{self.name}_opt_trj.zip" + + @property + def _opt_trajectory_exists(self) -> bool: + return os.path.exists(self._opt_trajectory_name) + + def _set_properties_from_optimiser(self) -> None: + """Set the properties from the trajectory file, that must exist""" + logger.info( + "Setting optimised coordinates, gradient and energy from " + "the reloaded optimiser state" + ) + + final_coords = self.optimiser.final_coordinates + if final_coords is None: + raise ex.CalculationException("Final coordinates undefined") + + cart_coords = final_coords.to("cart") + self.molecule.coordinates = cart_coords.reshape((-1, 3)) + if cart_coords.g is not None: + self.molecule.gradient = cart_coords.g.reshape((-1, 3)) + + self.molecule.energy = final_coords.e + return None + + +class CalculationExecutorG(_IndirectCalculationExecutor): + """Calculation executor with a numerical gradient evaluation""" + + def run(self) -> None: + raise NotImplementedError + + +class CalculationExecutorH(_IndirectCalculationExecutor): + """Calculation executor with a numerical Hessian evaluation""" + + def run(self) -> None: + logger.warning( + f"{self.method} does not implement Hessian " + f"calculations. Evaluating a numerical Hessian" + ) + + from autode.hessians import NumericalHessianCalculator + + nhc = NumericalHessianCalculator( + species=self.molecule, + method=self.method, + keywords=kws.GradientKeywords(self.input.keywords.tolist()), + do_c_diff=False, + shift=Distance(2e-3, units="Å"), + n_cores=self.n_cores, + ) + nhc.calculate() + self.molecule.hessian = nhc.hessian + + @property + def terminated_normally(self) -> bool: + """ + This calculation executor terminated normally if the Hessian exists and + did not raise any exceptions along the way + + ----------------------------------------------------------------------- + Returns: + (bool): + """ + return self.molecule.hessian is not None + + +def _string_without_leading_hyphen(s: str) -> str: + return s if not s.startswith("-") else f"_{s}" + + +def _active_bonds(molecule: "Species") -> List[Tuple[int, int]]: + return [] if molecule.graph is None else molecule.graph.active_bonds diff --git a/autodE/source/autode/calculations/input.py b/autodE/source/autode/calculations/input.py new file mode 100644 index 0000000000000000000000000000000000000000..6203a7d19c2ff45cf997ef3b0bb00b9173d6b394 --- /dev/null +++ b/autodE/source/autode/calculations/input.py @@ -0,0 +1,72 @@ +import os +import autode.wrappers.keywords as kws + +from typing import Optional, List, TYPE_CHECKING +from autode.point_charges import PointCharge + +if TYPE_CHECKING: + from autode.wrappers.keywords import Keywords + + +class CalculationInput: + def __init__( + self, + keywords: "Keywords", + added_internals: Optional[list] = None, + point_charges: Optional[List[PointCharge]] = None, + ): + """ + Calculation input + + ----------------------------------------------------------------------- + Arguments: + keywords: Keywords that a method will use to run the calculation + e.g. ['pbe', 'def2-svp'] for an ORCA single point at + PBE/def2-SVP + + added_internals: Atom indexes to add to the internal coordinates + + point_charges: Optional list of float of point charges, x, y, z + coordinates for each point charge + """ + self.keywords: Keywords = keywords.copy() + + self.added_internals: Optional[list] = None + if added_internals is not None and len(added_internals) > 0: + self.added_internals = added_internals + + self.point_charges = point_charges + + self.filename: Optional[str] = None + self.additional_filenames: List[str] = [] + + self._check() + + def _check(self): + """Check that the input parameters have the expected format""" + if self.keywords is not None: + assert isinstance(self.keywords, kws.Keywords) + + # Ensure the point charges are given as a list of PointCharge objects + if self.point_charges is not None: + assert type(self.point_charges) is list + assert all(type(pc) is PointCharge for pc in self.point_charges) + + if self.added_internals is not None: + assert type(self.added_internals) is list + assert all(len(idxs) == 2 for idxs in self.added_internals) + + @property + def exists(self): + """Does the input (files) exist?""" + return self.filename is not None and all( + os.path.exists(fn) for fn in self.filenames + ) + + @property + def filenames(self): + """Return a list of all the input files""" + if self.filename is None: + return self.additional_filenames + + return [self.filename] + self.additional_filenames diff --git a/autodE/source/autode/calculations/output.py b/autodE/source/autode/calculations/output.py new file mode 100644 index 0000000000000000000000000000000000000000..37b491cc154de2bf2ac6f3ad005d92665fa9d126 --- /dev/null +++ b/autodE/source/autode/calculations/output.py @@ -0,0 +1,87 @@ +import os +import autode.exceptions as ex + +from typing import Optional, List +from functools import cached_property +from autode.log import logger + + +class CalculationOutput: + def __init__(self, filename: Optional[str] = None): + self._filename = filename + + @property + def filename(self) -> Optional[str]: + return self._filename + + @filename.setter + def filename(self, value: str): + self._filename = str(value) + self.clear() + + @cached_property + def file_lines(self) -> List[str]: + """ + Output files lines. This may be slow for large files but should + not become a bottleneck when running standard DFT/WF calculations, + are cached so only read once + + ----------------------------------------------------------------------- + Returns: + (list(str)): Lines from the output file + + Raises: + (autode.exceptions.NoCalculationOutput): If the file doesn't exist + """ + logger.info("Setting output file lines") + + if self.filename is None or not os.path.exists(self.filename): + raise ex.NoCalculationOutput + + file = open(self.filename, "r", encoding="utf-8", errors="ignore") + return file.readlines() + + @property + def exists(self) -> bool: + """Does the calculation output exist?""" + return self.filename is not None and os.path.exists(self.filename) + + def clear(self) -> None: + """Clear the cached file lines""" + + if "file_lines" in self.__dict__: + del self.__dict__["file_lines"] + + return None + + def try_to_print_final_lines(self, n: int = 50) -> None: + """ + Attempt to print the final n output lines, if the output exists + + ----------------------------------------------------------------------- + Arguments: + n: Number of lines + """ + + if self.exists: + print("".join(self.file_lines[-n:])) + + return None + + +class BlankCalculationOutput(CalculationOutput): + @property + def filename(self) -> Optional[str]: + return None + + @filename.setter + def filename(self, value: str): + raise ValueError("Cannot set the filename of a blank output") + + @property + def file_lines(self) -> List[str]: + return [] + + @property + def exists(self) -> bool: + return True diff --git a/autodE/source/autode/calculations/types.py b/autodE/source/autode/calculations/types.py new file mode 100644 index 0000000000000000000000000000000000000000..83a419eb336b42d7e5fe3406598efde67caf830f --- /dev/null +++ b/autodE/source/autode/calculations/types.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class CalculationType(Enum): + """Enum defining a mode/type of a calculation""" + + opt = 0 + energy = 1 + gradient = 2 + hessian = 3 diff --git a/autodE/source/autode/common/NEB.pdf b/autodE/source/autode/common/NEB.pdf new file mode 100644 index 0000000000000000000000000000000000000000..9f43f1d33b8d08a432063736a42cc0b94da9019e --- /dev/null +++ b/autodE/source/autode/common/NEB.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:694f945d2516a306819efe16cca784e12b6d657c7bae52e989ba4b0368b3899a +size 106121 diff --git a/autodE/source/autode/common/NEB.tex b/autodE/source/autode/common/NEB.tex new file mode 100644 index 0000000000000000000000000000000000000000..91576114f7db86e3151b177b4ae60d454305c29e --- /dev/null +++ b/autodE/source/autode/common/NEB.tex @@ -0,0 +1,60 @@ +\documentclass[10pt]{article} +\usepackage{bm}% bold math +\usepackage{amsmath} + +\begin{document} + +\subsection{Original NEB} + +Nudged elastic band (NEB) approaches to locating transition states are efficient alternatives to evaluating the PES on a uniform grid over some coordinates of interest. The implementation in \emph{autodE} follows that in [\emph{J. Chem. Phys.}, 2000, {\bfseries{113}}, 9978] +\\\\ +For an image $i$ in the nudged elastic band +\begin{equation} +\boldsymbol{\tau}_i = +\begin{cases} +\boldsymbol{\tau}_i^+ &\quad\text{if}\quad V_{i-1} < V_i < V_{i+1} \\ +\boldsymbol{\tau}_i^- &\quad\text{if}\quad V_{i+1} < V_i < V_{i-1} \\ +\boldsymbol{\tau}_i^+\Delta V_i^{max} + \boldsymbol{\tau}_i^-\Delta V_i^{min} &\quad\text{if}\quad V_{i-1} < V_{i+1} \\ +\boldsymbol{\tau}_i^+\Delta V_i^{min} + \boldsymbol{\tau}_i^-\Delta V_i^{max} &\quad\text{if}\quad V_{i+1} < V_{i-1} \\ +\end{cases} +\end{equation} +where +\begin{equation} +\begin{aligned} +\boldsymbol{\tau}_i^+ &= \boldsymbol{x}_{i+1} - \boldsymbol{x}_i \\ +\boldsymbol{\tau}_i^- &= \boldsymbol{x}_{i} - \boldsymbol{x}_{i-1} +\end{aligned} +\end{equation} +and +\begin{equation} +\begin{aligned} +\Delta V_i^{max} &= \max(|V_{i+1} - V_i|, |V_{i-1} - V_i|) \\ +\Delta V_i^{min} &= \min(|V_{i+1} - V_i|, |V_{i-1} - V_i|) +\end{aligned} +\end{equation} +and $\boldsymbol{x}_i$ are the coordinates of image $i$. The spring force is +\begin{equation} +\boldsymbol{F}^s_i|_{\parallel} = (k_i|\boldsymbol{x}_{i+1} - \boldsymbol{x}_i| - k_{i-1}|\boldsymbol{x}_i - \boldsymbol{x}_{i-1}|) \hat{\boldsymbol{\tau}}_i +\end{equation} +and the total force on the image +\begin{equation} +\boldsymbol{F}_i = \boldsymbol{F}^s_i|_{\parallel} - \nabla V(\boldsymbol{x}_i)|_\perp +\end{equation} +where +\begin{equation} +\nabla V(\boldsymbol{x}_i)|_\perp = \nabla V(\boldsymbol{x}_i) - \nabla V(\boldsymbol{x}_i)\cdot \hat{\boldsymbol{\tau}}_i\hat{\boldsymbol{\tau}}_i +\end{equation} +and finally $\hat{\boldsymbol{\tau}} = \boldsymbol{\tau}_i/|\boldsymbol{\tau}_i|$. +\\\\ +\subsection{CI-NEB} + +The climbing image (CI) NEB implementation follows that in [\emph{J. Chem. Phys.}, 2000, {\bfseries{113}}, 9901] where after a few iterations the force on the maximum energy image ($m$) is given by + +\begin{equation} +\boldsymbol{F}_{m} = -\nabla V(\boldsymbol{x}_m) + 2\nabla V(\boldsymbol{x}_m)\cdot \hat{\boldsymbol{\tau}}_i\hat{\boldsymbol{\tau}}_i +\end{equation} + +which is the force due to the potential along the band being inverted. + + +\end{document} \ No newline at end of file diff --git a/autodE/source/autode/common/adaptive_path.pdf b/autodE/source/autode/common/adaptive_path.pdf new file mode 100644 index 0000000000000000000000000000000000000000..73e5273baa4a6845a1497258e498f374c8d9534b Binary files /dev/null and b/autodE/source/autode/common/adaptive_path.pdf differ diff --git a/autodE/source/autode/common/adaptive_path.tex b/autodE/source/autode/common/adaptive_path.tex new file mode 100644 index 0000000000000000000000000000000000000000..083b3c0855e9f482f800bccfb1a6c560d925de0a --- /dev/null +++ b/autodE/source/autode/common/adaptive_path.tex @@ -0,0 +1,32 @@ +\documentclass[10pt]{article} +\usepackage{bm}% bold math +\usepackage{amsmath} +\DeclareMathOperator{\sgn}{sgn} + +\begin{document} + +\subsection{Adaptive Path} + +The adaptive path algorithm in \emph{autodE} attempts to traverse the minimum energy pathway from reactants to products with constrained optimisations using a gradient dependent step size. The initial constraints for the first point are + +\begin{equation} +r_b^{(1)} = r_b^{(0)} + \sgn(r_b^\text{final} - r_b^{(0)})\Delta r_\text{init} +\end{equation} +\\ +for a bond $b$, where the superscript denotes the current step. $\Delta r_\text{init}$ is an initial step size, e.g. 0.2 Å. Constraints for subsequent steps are then given by +\\\\ +\begin{equation} +r_b^{(k)} = r_b^{(k-1)} + \sgn(r_b^\text{final} - r_b^{(0)})\Delta r_b^{(k-1)} +\end{equation} + +\begin{equation} +\Delta r_b^{(k)} = +\begin{cases} +\Delta r_\text{max} \quad &\text{if } \sgn(r_b^\text{final} - r_b^{(0)}) \nabla E_{j} \cdot \boldsymbol{r}_{ij} > 0 \\ +\Delta r_\text{m}\exp\left[-\left({\nabla E_{j}^{(k)} \cdot \boldsymbol{r}_{ij}}/{g} \right)^2\right] + \Delta r_\text{min} \quad &\text{otherwise} +\end{cases} +\end{equation} +\\ +where $\Delta r_\text{m} = \Delta r_\text{max} - \Delta r_\text{min}$, $E$ the total potential energy (in the absence of any harmonic constraints) and $g$ a parameter to control the interpolation between $\Delta r_\text{max}$ and $\Delta r_\text{min}$ e.g. 0.05 Ha Å$^{-1}$. Atom indices $i, j$ form part of the bond indexed by $b$ with $j$ being an atom not being substituted. In the case that neither $i$ nor $j$ are being substituted the gradient is taken as an average over $i$ and $j$. + +\end{document} \ No newline at end of file diff --git a/autodE/source/autode/common/hessians.pdf b/autodE/source/autode/common/hessians.pdf new file mode 100644 index 0000000000000000000000000000000000000000..c80b575c4bdf1b6139e1e0b7110e69c28ea661c1 --- /dev/null +++ b/autodE/source/autode/common/hessians.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8dfd38c9dbaf48e024f972a70c7546b08390c644af5cc839712c49bb4532426c +size 154155 diff --git a/autodE/source/autode/common/hessians.tex b/autodE/source/autode/common/hessians.tex new file mode 100644 index 0000000000000000000000000000000000000000..666febbc9a9ca2eb1bda61461e9660145e5d6409 --- /dev/null +++ b/autodE/source/autode/common/hessians.tex @@ -0,0 +1,192 @@ +\documentclass[10pt]{article} +\usepackage{bm}% bold math +\usepackage{amsmath} +\usepackage{amssymb} +\usepackage{color} +\DeclareMathOperator{\sgn}{sgn} +\renewcommand{\thefootnote}{\alph{footnote}} + +\begin{document} + +\subsection{Hessian Diagonalization} + +Frequencies and normal modes are obtained from Hessian diagonalization, following the method from {\color{blue} https://tinyurl.com/4a75skfm}, which in turn uses (V. Barone, JCP, 2005, 122, 014108; V. Barone et al. IJQ. Chem., 2012, 112, 2185). Without projection frequencies and normal modes are just (transformed) eigenvalues and eigenvectors of the Hessian, + +\begin{equation} + \mathsf{H} = \begin{pmatrix} + \frac{\partial^2 E}{\partial x_1^2} & \frac{\partial^2 E}{\partial x_1y_1} & + \frac{\partial^2 E}{\partial x_1z_1} & \cdots\\ + \frac{\partial^2 E}{\partial y_1x_1} & \frac{\partial^2 E}{\partial y_1^2} & + \frac{\partial^2 E}{\partial y_1z_1} & \cdots\\ + \vdots & \vdots & \vdots & \ddots + \end{pmatrix} +\end{equation} +\\ +appropriately mass weighted, +\begin{equation} + \mathsf{H}_\text{w} = \begin{pmatrix} + \frac{\mathsf{H}_{11}}{\sqrt{m_1 m_1}} & \cdots & \frac{\mathsf{H}_{1,3i}}{\sqrt{m_1 m_i}} & \cdots \\ + \vdots & \vdots & \vdots & \ddots + \end{pmatrix} +\end{equation} +\\ +which is real symmetric so Hermitian ($\mathsf{H} \in \mathbb{R}^{3N\times3N}$ for a system of $N$ atoms). The frequencies are then square roots of the eigenvalues i.e. $\nu_i = \sqrt{\lambda_i}$\footnote{With an appropriate unit conversion.} and the normal modes $\boldsymbol{s}_i$ where, + +\begin{equation} + \mathsf{H}_\text{w} = \mathsf{S D S}^T \quad ; \quad \mathsf{D} = \begin{pmatrix} + \lambda_1 & 0 & \cdots \\ + 0 & \lambda_2 & \cdots \\ + \vdots & \vdots & \ddots + \end{pmatrix} + % + \quad ; \quad + % + \mathsf{S} = \begin{pmatrix} + \uparrow & \uparrow & \\ + \boldsymbol{s}_1 & \boldsymbol{s}_2 & \cdots \\ + \downarrow & \downarrow & + \end{pmatrix} +\end{equation} +\\ +To project out translational and rotational motion for a non linear molecule requires a transformation of $\mathsf{H}_\text{w}$, + +\begin{equation} + \mathsf{H}_\text{w}' = \mathsf{T}^T \mathsf{H}_\text{w} \mathsf{T} \qquad ; \qquad \mathsf{H}_\text{w}' = \begin{pmatrix} + \boldsymbol{0} & \boldsymbol{0} \\ + \boldsymbol{0} & \bar{\mathsf{H}}_\text{w} + \end{pmatrix} +\end{equation} +\\ +where +\begin{equation} + \mathsf{T} = \begin{pmatrix} + \uparrow & \uparrow & \\ + \hat{\boldsymbol{t}}_1 & \hat{\boldsymbol{t}}_2 & \cdots \\ + \downarrow & \downarrow & + \end{pmatrix} +\end{equation} +\\ +and the columns of $\mathsf{M}$ are, + +\begin{equation} + \boldsymbol{t}_1 = \begin{bmatrix} + (\hat{\boldsymbol{e}}_1)_1 \\ + \vdots \\ + (\hat{\boldsymbol{e}}_1)_N \\ + \end{bmatrix} + % + \quad ; \quad + % + \boldsymbol{t}_2 = \begin{bmatrix} + (\hat{\boldsymbol{e}}_2)_1 \\ + \vdots \\ + (\hat{\boldsymbol{e}}_2)_N \\ + \end{bmatrix} + % + \quad ; \quad + % + \boldsymbol{t}_3 = \begin{bmatrix} + (\hat{\boldsymbol{e}}_3)_1 \\ + \vdots \\ + (\hat{\boldsymbol{e}}_3)_N \\ + \end{bmatrix} +\end{equation} +\\ +where $\hat{\boldsymbol{e}}_k$ is a unit vector in 3D (i.e. $\hat{\boldsymbol{e}}_1 = (1, 0, 0)^T$). The rotation vectors are + +\begin{equation} + \boldsymbol{t}_4 = \begin{bmatrix} + \boldsymbol{e}_1 \times \boldsymbol{r}_1 \\ + \vdots \\ + \boldsymbol{e}_1 \times \boldsymbol{r}_N \\ + \end{bmatrix} + % + \quad ; \quad + % + \boldsymbol{t}_5 = \begin{bmatrix} + \boldsymbol{e}_2 \times \boldsymbol{r}_1 \\ + \vdots \\ + \boldsymbol{e}_2 \times \boldsymbol{r}_N \\ + \end{bmatrix} + % + \quad ; \quad + % + \boldsymbol{t}_6 = \begin{bmatrix} + \boldsymbol{e}_3 \times \boldsymbol{r}_1 \\ + \vdots \\ + \boldsymbol{e}_3 \times \boldsymbol{r}_N \\ + \end{bmatrix} +\end{equation} +\\ +where $\boldsymbol{r}_i$ is the vector from the centre of mass of the system to the atom $i$. The remaining $\boldsymbol{t}_n$ are filled with random vectors that are orthogonal to $\boldsymbol{t}_1\text{--}\boldsymbol{t}_6$, which can be achieved by QR factorisation once the remaining elements of $\mathsf{T}$ have been seeded with random numbers. Normalisation requires, + +\begin{equation} + \hat{\boldsymbol{t}}_i = \frac{\mathsf{M}^{1/2}\boldsymbol{t}_i}{|\mathsf{M}^{1/2}\boldsymbol{t}_i|} + % + \qquad ; \qquad + % + \mathsf{M} = \begin{pmatrix} + m_1 & 0 & 0 & 0 &\cdots \\ + 0 & m_1 & 0 & 0& \cdots \\ + 0 & 0 & m_1 & 0& \cdots \\ + 0 & 0 & 0 & m_2 & \cdots \\ + \vdots & \vdots & \vdots & \vdots & \ddots + \end{pmatrix} +\end{equation} +\\ +where $m_i$ is the mass of atom $i$. + +\vspace{0.4cm} + +Projected frequencies are then obtained from the submatrix of $\mathsf{H}_\text{w}'$, + + +\begin{equation} + \bar{\mathsf{H}}_\text{w} = \mathsf{\bar{S} \bar{D}\bar{S}}^T + % + \quad ; \quad + % + \bar{\mathsf{S}} = + \begin{pmatrix} + \uparrow & \\ + \bar{\boldsymbol{s}}_7 & \cdots \\ + \downarrow & + \end{pmatrix} + % + \quad ; \quad + % + \bar{\mathsf{D}} = + \begin{pmatrix} + \bar{\lambda}_7 & 0& \cdots \\ + 0 & \bar{\lambda}_8 & \cdots \\ + \vdots & \vdots & \ddots + \end{pmatrix} +\end{equation} +with $\bar{\nu}_{0\text{--}6} = 0$ cm${}^{-1}$, while the eigenvectors are, + +\begin{equation} + \boldsymbol{s}_i = \mathsf{T}\boldsymbol{s}_i' + % + \quad ; \quad + % + \mathsf{S}' = \begin{pmatrix} + \uparrow & \\ + \boldsymbol{s}_1' & \cdots\\ + \downarrow & + \end{pmatrix} + = + \begin{pmatrix} + \boldsymbol{0} & \boldsymbol{0} \\ + \boldsymbol{0} & \bar{\mathsf{S}} + \end{pmatrix} +\end{equation} +\\ +which correspond to the normal modes in the original coordinates. For a linear molecule the vibrational frequencies are then the $3N-5$ modes, rather than $3N-6$, with $\mathsf{H}_w'$ contains a different number of non-zero entries. + + + + + + + +\end{document} \ No newline at end of file diff --git a/autodE/source/autode/common/llogo.png b/autodE/source/autode/common/llogo.png new file mode 100644 index 0000000000000000000000000000000000000000..aec1cced7a46479e41ca69473bda70815f3ba345 --- /dev/null +++ b/autodE/source/autode/common/llogo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:595f485e0ae062f0783fb1adce4f54ae2aac6b6b0687f44671dc196f5b2ae38f +size 108344 diff --git a/autodE/source/autode/common/logo.pages b/autodE/source/autode/common/logo.pages new file mode 100644 index 0000000000000000000000000000000000000000..91c49fba188f3bb7586d6f4d66274557c2565c39 --- /dev/null +++ b/autodE/source/autode/common/logo.pages @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:408f692d6bc6bc309fcf189fa15a7de7eb44f2e7726c9ada3379d1ff3f8d3d86 +size 101212 diff --git a/autodE/source/autode/common/thermochemistry.pdf b/autodE/source/autode/common/thermochemistry.pdf new file mode 100644 index 0000000000000000000000000000000000000000..3ff2d895eefce65202da4593e186cf2c77f109d9 --- /dev/null +++ b/autodE/source/autode/common/thermochemistry.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3372faf8528e9aa2118cf95289cff147ed553bc10a5f9cad0dd4169184a5dc1c +size 119353 diff --git a/autodE/source/autode/common/thermochemistry.tex b/autodE/source/autode/common/thermochemistry.tex new file mode 100644 index 0000000000000000000000000000000000000000..b401ad9436432c258fb08c2b712659d923ded703 --- /dev/null +++ b/autodE/source/autode/common/thermochemistry.tex @@ -0,0 +1,91 @@ +\documentclass[10pt]{article} +\usepackage{bm}% bold math +\usepackage{amsmath} +\usepackage{amssymb} +\usepackage{color} +\DeclareMathOperator{\sgn}{sgn} +\renewcommand{\thefootnote}{\alph{footnote}} + +\begin{document} + +\subsection{Ideal Gas Model} + +From (McQuarrie, Statistical mechanics, 2000) the ideal gas method (IGM) for calculating an absolute free energy is outlined below. +\begin{equation} + G = H - TS +\end{equation} +\begin{equation} + H = U + RT +\end{equation} +\begin{equation} + U = E_\text{pot} + E_\text{ZPE} + E_\text{trns} + E_\text{rot} + E_\text{vib} \end{equation} +\begin{equation} + S = S_\text{trns} + S_\text{rot} + S_\text{vib} + S_\text{elec} +\end{equation} + +where $T$ is temperature, $R$ the ideal gas constant and $S_\text{elec}$ is taken to be zero for all molecules. The internal energy components are then + +\begin{equation} + E_\text{ZPE} = \frac{N_a}{2}\sum_i h \nu_i +\end{equation} +\begin{equation} + E_\text{trns} = \frac{3}{2}RT +\end{equation} +\begin{equation} + E_\text{rot} = + \begin{cases} + 0 &\quad \text{if } N = 1 \\ + RT &\quad \text{if linear} \\ + \frac{3}{2} RT &\quad \text{otherwise} + \end{cases} +\end{equation} +\begin{equation} + E_\text{vib} = R \sum_i \frac{\theta_i}{e^{\theta_i / T} - 1} \quad ,\quad \theta_i = h\nu_i / k_B +\end{equation} + +where $N_a$ is Avogadro's's constant, $N$ is the number of atoms in the molecule, $k_B$ Boltzmann's constant, $ \nu_i$ the $i$-th harmonic frequency and $h$ is Planks constant. The entropic components are + +\begin{equation} + S_\text{trns} = R \ln(q_\text{trns}) + \frac{5}{2}R +\end{equation} +\begin{equation} + S_\text{rot} = \begin{cases} + 0 &\quad \text{if } N = 1 \\ + R \ln(q_\text{rot}) + R &\quad \text{if linear} \\ + R \ln(q_\text{rot}) + \frac{3}{2}R &\quad \text{otherwise} + \end{cases} +\end{equation} +\begin{equation} + S_\text{vib}^\text{HO} = R \sum_i \frac{\theta_i}{T(e^{\theta_i / T} - 1)} - \ln(1 - e^{-\theta_i / T}) +\end{equation} +\begin{equation} + q_\text{trans} = {\Big (} \frac{2\pi m k_B T}{h^2} {\Big )}^{3/2} V_\text{eff} \quad , \quad V_\text{eff} = \begin{cases} + k_B T / p^{\circ} \quad&\text{if 1 atm standard state} \\ + 1 / c^\circ N_a \quad&\text{if 1 M standard state} + \end{cases} +\end{equation} +\begin{equation} + q_\text{rot} = \frac{T^{3/2}}{\sigma_r} \sqrt{\frac{\pi}{\omega_r}} \quad,\quad \omega_r = \prod_{k} \frac{h^2}{8 \pi^2 k_B I_k} +\end{equation} + +where $q$ are molecular partition functions, $p^{\circ}$ is the standard pressure (1 atm) and $c^\circ$ the standard concentration (1 mol dm$^{-3}$), $\sigma_r$ is the rotational symmetry number for the molecule and $I_k$ a diagonal element of the moment of inertia matrix. +\\\\ +Due to the vibrational entropy contribution being overestimated for low frequency modes Thrular proposed a correction, which instead of summing over frequencies in $S_\text{vib}^\text{HO}$ does so over $\max(\nu_\text{thresh},\; \nu_i)$ to shift all low frequencies to a threshold value (\emph{J. Phys. Chem. B} 2011, {\bfseries{115}}, 14556). An alternative method from Grimme (\emph{Chem. Eur. J.}, 2012, {\bfseries{18}}, 9955) uses an interpolation between a harmonic oscillator and rigid rotor to scale down the contribution from the low frequency modes as +\begin{equation} + S_\text{vib}^\text{Grimme} = \sum_i w_i S_ \text{vib}^\text{HO}(i) + (1-w_i) {\Big (} R\ln {\Big (} \sqrt{\frac{8 \pi^3 \mu_i' k_B T}{h^2}} {\Big )} + \frac{R}{2} {\Big )} +\end{equation} +\begin{eqnarray} + \mu_i' = \frac{\mu_i \bar{B}}{\mu_i + \bar{B}} \quad,\quad \mu_i = \frac{h}{8\pi^2 \nu_i} \quad,\quad \bar{B} = \text{Tr}[I] / 3 +\end{eqnarray} +\begin{equation} + w_i = \frac{1}{1 + (\omega_0/ \nu_i)^\alpha} +\end{equation} +where $\omega_0$ and $\alpha$ are adjustable parameters. + + + + + + + +\end{document} \ No newline at end of file diff --git a/autodE/source/autode/config.py b/autodE/source/autode/config.py new file mode 100644 index 0000000000000000000000000000000000000000..48f2522476836aea22d21a80d71d21397d73ad4d --- /dev/null +++ b/autodE/source/autode/config.py @@ -0,0 +1,459 @@ +import os +from typing import Any +from autode.values import Frequency, Distance, Allocation +from autode.wrappers.keywords import implicit_solvent_types as solv +from autode.wrappers.keywords import KeywordsSet, MaxOptCycles +from autode.wrappers.keywords.basis_sets import ( + def2svp, + def2tzvp, + def2ecp, + def2tzecp, +) +from autode.wrappers.keywords.functionals import pbe0 +from autode.wrappers.keywords.dispersion import d3bj +from autode.wrappers.keywords.ri import rijcosx + +location = os.path.abspath(__file__) + + +class _ConfigClass: + # ------------------------------------------------------------------------- + # Total number of cores available + # + n_cores = 4 + # ------------------------------------------------------------------------- + # Per core memory available + # + max_core = Allocation(4, units="GB") + # ------------------------------------------------------------------------- + # DFT code to use. If set to None then the highest priority available code + # will be used: + # 1. 'orca', 2. 'g09' 3. 'nwchem' + # + hcode = None + # ------------------------------------------------------------------------- + # Semi-empirical/tight binding method to use. If set to None then the + # highest priority available will be used: 1. 'xtb', 2. 'mopac' + # + lcode = None + # ------------------------------------------------------------------------- + # When using explicit solvent is stable this will be uncommented + # + # explicit_solvent = False + # + # ------------------------------------------------------------------------- + # Setting to keep input files, otherwise they will be removed + # + keep_input_files = True + # ------------------------------------------------------------------------- + # Use a different base directory for calculations with low-level methods + # e.g. /dev/shm with a low level method, if None then will use the default + # in tempfile.mkdtemp + # + ll_tmp_dir = None + # ------------------------------------------------------------------------- + # By default templates are saved to /path/to/autode/transition_states/lib/ + # unless ts_template_folder_path is set + # + ts_template_folder_path = None + # ------------------------------------------------------------------------- + # Whether or not to create and save transition state templates + # + make_ts_template = True + # ------------------------------------------------------------------------- + # Save plots with dpi = 400 + # + high_quality_plots = True + # ------------------------------------------------------------------------- + # RMSD in angstroms threshold for conformers. Larger values will remove + # more conformers that need to be calculated but also reduces the chance + # that the lowest energy conformer is found + # + rmsd_threshold = Distance(0.3, units="Å") + # ------------------------------------------------------------------------- + # Total number of conformers generated in find_lowest_energy_conformer() + # for single molecules/TSs + # + num_conformers = 300 + # ------------------------------------------------------------------------- + # Maximum random displacement in angstroms for conformational searching + # + max_atom_displacement = Distance(4.0, units="Å") + # ------------------------------------------------------------------------- + # Number of evenly spaced points on a sphere that will be used to generate + # NCI and Reactant and Product complex conformers. Total number of + # conformers will be: + # (num_complex_sphere_points × + # num_complex_random_rotations) ^ (n molecules in complex - 1) + # + num_complex_sphere_points = 10 + # ------------------------------------------------------------------------- + # Number of random rotations of a molecule that is added to a NCI or + # Reactant/Product complex + # + num_complex_random_rotations = 10 + # ------------------------------------------------------------------------- + # For more than 2 molecules in a complex the conformational space explodes, + # so limit the maximum number to this value + # + max_num_complex_conformers = 300 + # ------------------------------------------------------------------------- + # Use the high + low level method to find the lowest energy + # conformer, to use energies at the low_opt level of the low level code + # set this to False + # + hmethod_conformers = True + # ------------------------------------------------------------------------- + # Set to True to use single point energy evaluations to rank conformers and + # select the lowest energy. Requires keywords.low_sp to be set and + # hmethod_conformers = True + # WARNING: This relies on the low-level geometry being accurate enough for + # the system in question – switching this on without benchmarking may lead + # to large errors! + # + hmethod_sp_conformers = False + # ------------------------------------------------------------------------- + # Use adaptive force constant modification in NEB calculations to improve + # sampling around the saddle point + # + adaptive_neb_k = True + # ------------------------------------------------------------------------- + # Minimum and maximum step size to use for the adaptive path search + # + min_step_size = Distance(0.05, units="Å") + max_step_size = Distance(0.3, units="Å") + # ------------------------------------------------------------------------- + # Heuristic for pruning the bond rearrangement set. If there are only bond + # rearrangements that involve small rings then TSs involving small rings + # are possible. However, when there are multiple possibilities involving + # the same set of atoms then discard any rearrangements that would involve + # a 3 or 4-membered TS e.g. skip the possible 4-membered TS for a Cope + # rearrangement in hexadiene + # + skip_small_ring_tss = True + # ------------------------------------------------------------------------- + # Minimum magnitude of the imaginary frequency (cm-1) to consider for a + # 'true' TS. For very shallow saddle points this may need to be reduced + # to e.g. -10 cm-1. Although most TSs have |v_imag| > 100 cm-1 this + # threshold is designed to be conservative + # + min_imag_freq = Frequency(-40, units="cm-1") + # ------------------------------------------------------------------------- + # Configuration parameters for ideal gas free energy calculations. Can be + # configured to use different standard states, quasi-rigid rotor harmonic + # oscillator (qRRHO) or pure RRHO + # + # One of: '1M', '1atm' + standard_state = "1M" + # + # Method to treat low frequency modes (LFMs). Either standard RRHO ('igm'), + # Truhlar's method where all frequencies below a threshold are scaled to + # a shifted value (see J. Phys. Chem. B, 2011, 115, 14556), Grimme's + # method of interpolating between HO and RR (i.e. qRRHO, see + # Chem. Eur. J. 2012, 18, 9955), or 'minenkov' where free rotor/vibrational + # interpolation is useed for U and S (i.e. mRRHO, see + # J. Comput. Chem., 2023 44, 1807) + # + # One of: 'igm', 'truhlar', 'grimme', 'minenkov' + lfm_method = "grimme" + # + # Parameters for Grimme's method (only used when lfm_method='grimme'), + # w0 is a frequency in cm-1 + grimme_w0 = Frequency(100, units="cm-1") + grimme_alpha = 4 + # + # Parameters for Truhlar's method (only used when lfm_method='truhlar') + # vibrational frequencies below this value (cm-1) will be shifted to this + # value before the entropy is calculated + vib_freq_shift = Frequency(100, units="cm-1") + # ------------------------------------------------------------------------- + # Frequency scale factor, useful for DFT functions known to have a + # systematic error. This value must be between 0 and 1 inclusive. For + # example, PBEh-3c has a scale factor of 0.95. + # + freq_scale_factor = None + # ------------------------------------------------------------------------- + # Minimum number of atoms that are removed for truncation to be used in + # locating TSs. Below this number any truncation is skipped + # + min_num_atom_removed_in_truncation = 10 + # ------------------------------------------------------------------------- + # Flag for allowing free energies to be calculated with association + # complexes. This is *not* recommended to be turned on due to the + # approximations made in the entropy calculations. + # + allow_association_complex_G = False + # ------------------------------------------------------------------------- + # Flag to allow use of an experimental timeout function wrapper for + # Windows, using loky. The default case has no timeout for Windows, and + # timeout only works on Linux/macOS. This flag is ignored on Linux/macOS. + # + use_experimental_timeout = False + # ------------------------------------------------------------------------- + + class ORCA: + # --------------------------------------------------------------------- + # Parameters for orca https://sites.google.com/site/orcainputlibrary/ + # --------------------------------------------------------------------- + # + # Path can be unset and will be assigned if it can be found in $PATH + path = None + # + # File extensions to copy when a calculation completes + copied_output_exts = [".out", ".hess", ".xyz", ".inp", ".pc"] + + optts_block = ( + "\n%geom\n" + "Calc_Hess true\n" + "Recalc_Hess 20\n" + "Trust -0.1\n" + "MaxIter 100\n" + "end" + ) + + keywords = KeywordsSet( + low_opt=[ + "LooseOpt", + pbe0, + rijcosx, + d3bj, + def2svp, + "def2/J", + MaxOptCycles(10), + ], + grad=["EnGrad", pbe0, rijcosx, d3bj, def2svp, "def2/J"], + low_sp=["SP", pbe0, rijcosx, d3bj, def2svp, "def2/J"], + opt=["Opt", pbe0, rijcosx, d3bj, def2svp, "def2/J"], + opt_ts=[ + "OptTS", + "Freq", + pbe0, + rijcosx, + d3bj, + def2svp, + "def2/J", + optts_block, + ], + hess=["Freq", pbe0, rijcosx, d3bj, def2svp, "def2/J"], + sp=["SP", pbe0, rijcosx, d3bj, def2tzvp, "def2/J"], + ecp=def2ecp, + ) + + # Implicit solvent in ORCA is either treated with CPCM or SMD, the + # former has support for a VdW surface construction which provides + # better geometry convergence (https://doi.org/10.1002/jcc.26139) SMD + # is in general more accurate, but does not (yet) have support for the + # VdW charge scheme. Use either (1) solv.cpcm, (2) solv.smd + implicit_solvation_type = solv.cpcm + + class G09: + # --------------------------------------------------------------------- + # Parameters for g09 https://gaussian.com/glossary/g09/ + # --------------------------------------------------------------------- + # + # path can be unset and will be assigned if it can be found in $PATH + path = None + # + grid = "integral=ultrafinegrid" + optts_block = ( + "Opt=(TS, CalcFC, NoEigenTest, MaxCycles=100, " + "MaxStep=10, NoTrustUpdate)" + ) + + keywords = KeywordsSet( + low_opt=[pbe0, def2svp, "Opt=Loose", MaxOptCycles(10), d3bj, grid], + grad=[pbe0, def2svp, "Force(NoStep)", d3bj, grid], + low_sp=[pbe0, def2svp, d3bj, grid], + opt=[pbe0, def2svp, "Opt", d3bj, grid], + opt_ts=[pbe0, def2svp, "Freq", d3bj, grid, optts_block], + hess=[pbe0, def2svp, "Freq", d3bj, grid], + sp=[pbe0, def2tzvp, d3bj, grid], + ecp=def2tzecp, + ) + + # Only SMD implemented + implicit_solvation_type = solv.smd + + class G16: + # --------------------------------------------------------------------- + # Parameters for g16 https://gaussian.com/gaussian16/ + # --------------------------------------------------------------------- + # + # path can be unset and will be assigned if it can be found in $PATH + path = None + # + ts_str = ( + "Opt=(TS, CalcFC, NoEigenTest, MaxCycles=100, MaxStep=10, " + "NoTrustUpdate, RecalcFC=30)" + ) + + keywords = KeywordsSet( + low_opt=[pbe0, def2svp, "Opt=Loose", d3bj, MaxOptCycles(10)], + grad=[pbe0, def2svp, "Force(NoStep)", d3bj], + low_sp=[pbe0, def2svp, d3bj], + opt=[pbe0, def2svp, "Opt", d3bj], + opt_ts=[pbe0, def2svp, "Freq", d3bj, ts_str], + hess=[pbe0, def2svp, "Freq", d3bj], + sp=[pbe0, def2tzvp, d3bj], + ecp=def2tzecp, + ) + + # Only SMD implemented + implicit_solvation_type = solv.smd + + class NWChem: + # --------------------------------------------------------------------- + # Parameters for nwchem http://www.nwchem-sw.org/index.php/Main_Page + # --------------------------------------------------------------------- + # + # Path can be unset and will be assigned if it can be found in $PATH + path = None + # + # Note that the default NWChem level is PBE0 and PBE rather than + # PBE0-D3BJ and PBE-D3BJ as only D3 is available. The optimisation + # keywords contain 'gradient' as the optimisation is driven by autodE + keywords = KeywordsSet( + low_opt=[def2svp, pbe0, MaxOptCycles(10), "task dft gradient"], + grad=[def2svp, pbe0, "task dft gradient"], + low_sp=[def2svp, pbe0, "task dft energy"], + opt=[def2svp, pbe0, MaxOptCycles(100), "task dft gradient"], + opt_ts=[def2svp, pbe0, MaxOptCycles(50), "task dft gradient"], + hess=[def2svp, pbe0, "task dft freq"], + sp=[def2tzvp, pbe0, "task dft energy"], + ecp=def2ecp, + ) + + # Only SMD implemented + implicit_solvation_type = solv.smd + + class XTB: + # --------------------------------------------------------------------- + # Parameters for xtb https://github.com/grimme-lab/xtb + # --------------------------------------------------------------------- + # + # path can be unset and will be assigned if it can be found in $PATH + path = None + # + keywords = KeywordsSet() + # + # Only GBSA implemented + implicit_solvation_type = solv.gbsa + # + # Force constant used for harmonic restraints in constrained + # optimisations (Ha/a0) + force_constant = 2 + # + # Electronic temperature for all calculations (Kelvin) + # None means unset (default), set to 300.0 to have 300K for example + electronic_temp = None + # + # Version of xTB hamiltonian parameterisation: 0,1 or 2 + # corresponding to GFN0-xTB, GFN1-xTB, GFN2-xTB respectively + # When unset, uses the default + gfn_version = None + + class MOPAC: + # --------------------------------------------------------------------- + # Parameters for mopac http://openmopac.net + # --------------------------------------------------------------------- + # + # path can be unset and will be assigned if it can be found in $PATH + path = None + # + # Note: all optimisations at this low level will be in the gas phase + # using the keywords_list specified here. Solvent in mopac is defined + # by EPS and the dielectric + keywords = KeywordsSet(low_opt=["PM7", "PRECISE"]) + # + # Only COSMO implemented + implicit_solvation_type = solv.cosmo + + class QChem: + # --------------------------------------------------------------------- + # Parameters for QChem https://www.q-chem.com/ + # --------------------------------------------------------------------- + # + # path can be unset and will be assigned if it can be found in $PATH + path = None + # + # Default set of keywords to use for different types of calculation + keywords = KeywordsSet( + low_opt=[pbe0, def2svp, "jobtype opt", MaxOptCycles(10), d3bj], + grad=[pbe0, def2svp, "jobtype force", d3bj], + low_sp=[pbe0, def2svp, d3bj], + opt=[pbe0, def2svp, "jobtype opt", d3bj], + opt_ts=[pbe0, def2svp, "jobtype TS", d3bj], + hess=[pbe0, def2svp, "jobtype Freq", d3bj], + sp=[pbe0, def2tzvp, d3bj], + ecp=def2ecp, + ) + + # + # Only SMD is implemented + implicit_solvation_type = solv.smd + + # ========================================================================= + # ============= End ================== + # ========================================================================= + + def __setattr__(self, key, value): + """Custom setters""" + + if not hasattr(self, key): + raise KeyError(f"Cannot set {key}. Not present in ade.Config") + + if key == "max_core": + value = Allocation(value).to("MB") + + if key == "freq_scale_factor": + if value is not None: + if not (0.0 < value <= 1.0): + raise ValueError( + "Cannot set the frequency scale factor " + "outside of (0, 1]" + ) + + value = float(value) + + if key in ("max_atom_displacement", "min_step_size", "max_step_size"): + if float(value) < 0: + raise ValueError(f"Distances cannot be negative. Had: {value}") + + value = Distance(value).to("ang") + + return super().__setattr__(key, value) + + +def _instantiate_config_opts(cls: type) -> Any: + """ + Instantiate a config class containing options defined + as class variables. It generates an instance of the + class, and then creates instance variables of the same + name as class variables, recursively converting any + nested class into instances. + (This is required because class variables are not pickled, + only instance variables are) + + Args: + cls (type): Must be a class containing class + variables (not instance) + + Returns: + (Any): The generated class instance + """ + if not isinstance(cls, type): + raise ValueError("Must be a class, not an instance") + cls_instance = cls() + for name, attr in cls.__dict__.items(): + if name.startswith("__"): + continue + if isinstance(attr, type): + attr_val = _instantiate_config_opts(attr) # recursive + else: + attr_val = attr + setattr(cls_instance, name, attr_val) + return cls_instance + + +# Single instance of the configuration +Config = _instantiate_config_opts(_ConfigClass) diff --git a/autodE/source/autode/conformers/__init__.py b/autodE/source/autode/conformers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..85d1707544bc7af1c59f3b70cd6e549fdebfc4cd --- /dev/null +++ b/autodE/source/autode/conformers/__init__.py @@ -0,0 +1,4 @@ +from autode.conformers.conformer import Conformer +from autode.conformers.conformers import Conformers + +__all__ = ["Conformer", "Conformers"] diff --git a/autodE/source/autode/conformers/cconf_gen.pyx b/autodE/source/autode/conformers/cconf_gen.pyx new file mode 100644 index 0000000000000000000000000000000000000000..a90161d80ddc4c66f7012d0311819f1670936907 --- /dev/null +++ b/autodE/source/autode/conformers/cconf_gen.pyx @@ -0,0 +1,131 @@ +# cython: boundscheck=False +# cython: wraparound=False +# cython: cdivision=True +from cpython.array cimport array, clone +from libc.math cimport sqrt, pow +import numpy as np + + +cdef calc_energy(int n_atoms, array coords, int[:, :] bond_matrix, double k, double[:, :] d0, double c, + int exponent): + + cdef int i, j + cdef double delta_x = 0.0 + cdef double delta_y = 0.0 + cdef double delta_z = 0.0 + + cdef double d = 0.0 + cdef double repulsion = 0.0 + cdef double bonded = 0.0 + + cdef double energy = 0.0 + + + for i in range(n_atoms): + for j in range(n_atoms): + if i > j: + delta_x = coords.data.as_doubles[3*j] - coords.data.as_doubles[3*i] + delta_y = coords.data.as_doubles[3*j+1] - coords.data.as_doubles[3*i+1] + delta_z = coords.data.as_doubles[3*j+2] - coords.data.as_doubles[3*i+2] + d = sqrt(delta_x*delta_x + delta_y*delta_y + delta_z*delta_z) + + energy += c / pow(d, exponent) + + if bond_matrix[i][j] == 1: + energy += k * pow((d - d0[i][j]), 2) + + if bond_matrix[i][j] == 2: + energy += 10 * pow((d - d0[i][j]), 2) + return energy + +cdef calc_deriv(int n_atoms, array deriv, array coords, int[:, :] bond_matrix, + double k, double[:, :] d0, double c, int exponent): + + cdef int i, j + cdef double delta_x + cdef double delta_y + cdef double delta_z + + cdef double d + cdef double repulsion + cdef double bonded + cdef double fixed + + exponent_minus_2 = exponent + 2 + + for i in range(n_atoms): + for j in range(n_atoms): + if i != j: + delta_x = coords.data.as_doubles[3*j] - coords.data.as_doubles[3*i] + delta_y = coords.data.as_doubles[3*j+1] - coords.data.as_doubles[3*i+1] + delta_z = coords.data.as_doubles[3*j+2] - coords.data.as_doubles[3*i+2] + d = sqrt(delta_x*delta_x + delta_y*delta_y + delta_z*delta_z) + + repulsion = -exponent * c / pow(d, exponent_minus_2) + deriv.data.as_doubles[3*i] += repulsion * delta_x + deriv.data.as_doubles[3*i+1] += repulsion * delta_y + deriv.data.as_doubles[3*i+2] += repulsion * delta_z + + if bond_matrix[i][j] == 1: + bonded = 2.0 * k * (1.0 - d0[i][j]/d) + deriv.data.as_doubles[3*i] += bonded * delta_x + deriv.data.as_doubles[3*i+1] += bonded * delta_y + deriv.data.as_doubles[3*i+2] += bonded * delta_z + + if bond_matrix[i][j] == 2: + fixed = 20.0 * (1.0 - d0[i][j]/d) + deriv.data.as_doubles[3*i] += fixed * delta_x + deriv.data.as_doubles[3*i+1] += fixed * delta_y + deriv.data.as_doubles[3*i+2] += fixed * delta_z + + return -np.array(deriv) + + +def dvdr(py_flat_coords, py_bond_matrix, py_k, py_d0, py_c, py_exponent, + py_fixed_atoms): + + py_n_atoms = int(len(py_flat_coords) / 3) + cdef int n_atoms = py_n_atoms + cdef int[:, :] bond_matrix = py_bond_matrix + cdef double k = py_k + cdef double[:, :] d0 = py_d0 + cdef double c = py_c + cdef int i + cdef exponent = py_exponent + + cdef array coords, template = array('d') + coords = clone(template, 3*n_atoms, False) + init_array = clone(template, 3*n_atoms, False) + + # Initalise arrays + for i in range(3*n_atoms): + init_array[i] = 0.0 + coords[i] = py_flat_coords[i] + + dvdr = calc_deriv(n_atoms, init_array, coords, bond_matrix, k, d0, c, exponent) + + # Zero the gradients for all the fixed atoms + dvdr = dvdr.reshape(-1, 3) + dvdr[py_fixed_atoms, :] = 0.0 + + return dvdr.flatten() + + +def v(py_flat_coords, py_bond_matrix, py_k, py_d0, py_c, py_exponent, *args): + + py_n_atoms = int(len(py_flat_coords) / 3) + cdef int n_atoms = py_n_atoms + cdef int[:, :] bond_matrix = py_bond_matrix + cdef double k = py_k + cdef double[:, :] d0 = py_d0 + cdef double c = py_c + cdef exponent = py_exponent + + cdef array coords, template = array('d') + coords = clone(template, 3*n_atoms, False) + + cdef int i + for i in range(3*n_atoms): + coords[i] = py_flat_coords[i] + + return calc_energy(n_atoms, coords, bond_matrix, k, d0, c, exponent) diff --git a/autodE/source/autode/conformers/conf_gen.py b/autodE/source/autode/conformers/conf_gen.py new file mode 100644 index 0000000000000000000000000000000000000000..174e5e6ff76cc94db99464bfe0bb25c0ec88f47c --- /dev/null +++ b/autodE/source/autode/conformers/conf_gen.py @@ -0,0 +1,537 @@ +import os +import numpy as np +import autode as ade +from copy import deepcopy +from typing import Dict, Optional, TYPE_CHECKING +from itertools import combinations +from scipy.optimize import minimize + +from autode.conformers import Conformer +import autode.exceptions as ex +from autode.utils import log_time +from autode.input_output import xyz_file_to_atoms, atoms_to_xyz_file +from autode.mol_graphs import split_mol_across_bond +from autode.log import logger + +if TYPE_CHECKING: + from autode.species.species import Species + from autode.wrappers.keywords import Keywords + from autode.wrappers.methods import Method + + +def _get_bond_matrix(n_atoms, bonds, fixed_bonds): + """ + Populate a bond matrix with 1 if i, j are bonded, 2 if i, j are bonded and + fixed and 0 otherwise. Can support a partial structure with bonds to atoms + that don't (yet) exist. + + --------------------------------------------------------------------------- + Arguments: + n_atoms (int): + bonds (list(tuple)): + fixed_bonds (list(tuple)): + + Returns: + (np.ndarray): Bond matrix, shape = (n_atoms, n_atoms) + """ + bond_matrix = np.zeros((n_atoms, n_atoms), dtype=np.intc) + + for i, j in bonds: + if i < n_atoms and j < n_atoms: + bond_matrix[i, j] = 1 + bond_matrix[j, i] = 1 + for i, j in fixed_bonds: + if i < n_atoms and j < n_atoms: + bond_matrix[i, j] = 2 + bond_matrix[j, i] = 2 + + return bond_matrix + + +def _get_coords_energy( + coords, bonds, k, c, d0, tol, fixed_bonds, exponent=8, fixed_idxs=None +): + """ + Get the coordinates that minimise a FF with a bonds + repulsion FF + where the repulsion is c/r^exponent + + --------------------------------------------------------------------------- + Arguments: + coords (np.ndarray): Initial coordinates, shape = (n_atoms, 3) + bonds (list(tuple(int))): List of bonds + fixed_bonds (list(tuple(int))): List of constrained bonds will use 10k + as the harmonic force constant + k (float): + c (float): + + Keyword Arguments: + exponent (int): Exponent in the repulsive pairwise term + + Returns: + (np.ndarray): Optimised coordinates, shape = (n_atoms, 3) + """ + # TODO divide and conquer? + from cconf_gen import v + from cconf_gen import dvdr + + n_atoms = len(coords) + os.environ["OMP_NUM_THREADS"] = str(1) + + bond_matrix = _get_bond_matrix( + n_atoms=len(coords), bonds=bonds, fixed_bonds=fixed_bonds + ) + + if fixed_idxs is None: + fixed_idxs = np.array([], dtype=int) + + res = minimize( + v, + x0=coords.reshape(3 * n_atoms), + args=(bond_matrix, k, d0, c, exponent, fixed_idxs), + method="CG", + tol=tol, + jac=dvdr, + ) + + return res.x.reshape(n_atoms, 3), res.fun + + +def _get_v(coords, bonds, k, c, d0, fixed_bonds, exponent=8): + """Get the energy using a bond + repulsion FF where + + V(r) = Σ_bonds k(d - d0)^2 + Σ_ij c/d^exponent + + --------------------------------------------------------------------------- + Arguments: + coords (np.ndarray): shape = (n_atoms, 3) + bonds (list(tuple(int))): List of bonds + fixed_bonds (list(tuple(int))): List of constrained bonds will use 10k + as the harmonic force constant + k (float): + c (float): + exponent (int): Exponent in the repulsive pairwise term + + Returns: + (float): Energy + """ + from cconf_gen import v + + n_atoms = len(coords) + os.environ["OMP_NUM_THREADS"] = str(1) + + init_coords = coords.reshape(3 * n_atoms) + bond_matrix = _get_bond_matrix( + n_atoms=n_atoms, bonds=bonds, fixed_bonds=fixed_bonds + ) + + return v(init_coords, bond_matrix, k, d0, c, exponent) + + +def _get_atoms_rotated_stereocentres(species, atoms, rand): + """If two stereocentres are bonded, rotate them randomly with respect + to each other + + --------------------------------------------------------------------------- + Arguments: + species (autode.species.Species): + atoms (list(autode.atoms.Atom)): + rand (np.RandomState): random state + + Returns: + (list(autode.atoms.Atom)): Atoms + """ + + stereocentres = [ + node + for node in species.graph.nodes + if species.graph.nodes[node]["stereo"] is True + ] + + # Check on every pair of stereocenters + for i, j in combinations(stereocentres, 2): + if (i, j) not in species.graph.edges: + continue + + # Don't rotate if the bond connecting the centers is a π-bond + if species.graph.edges[i, j]["pi"] is True: + logger.info("Stereocenters were π bonded – not rotating") + continue + + try: + left_idxs, right_idxs = split_mol_across_bond( + species.graph, bond=(i, j) + ) + + except ex.CannotSplitAcrossBond: + logger.warning( + "Splitting across this bond does not give two " + "components - could have a ring" + ) + return atoms + + # Rotate the left hand side randomly + rot_axis = atoms[i].coord - atoms[j].coord + theta = 2 * np.pi * rand.rand() + idxs_to_rotate = left_idxs if i in left_idxs else right_idxs + + # Rotate all the atoms to the left of this bond, missing out i as that + # is the origin for rotation and thus won't move + for n in idxs_to_rotate: + if n == i: + continue + atoms[n].rotate(axis=rot_axis, theta=theta, origin=atoms[i].coord) + + return atoms + + +def _add_dist_consts_for_stereocentres(species, dist_consts): + """ + Add distances constraints across two bonded stereocentres, for example + for a Z alkene, (hopefully) ensuring that in the conformer generation the + stereochemistry is retained. Will also add distance constraints from + one nearest neighbour to the other nearest neighbours for that chiral + centre + + --------------------------------------------------------------------------- + Arguments: + species (autode.species.Species): + dist_consts (dict): keyed with tuple of atom indexes and valued with + the distance (Å), or None + + Returns: + (dict): Distance constraints + """ + if not ade.geom.are_coords_reasonable(coords=species.coordinates): + # TODO generate a reasonable initial structure: molassembler? + logger.error( + "Cannot constrain stereochemistry if the initial " + "structure is not sensible" + ) + return dist_consts + + stereocentres = [ + node + for node in species.graph.nodes + if species.graph.nodes[node]["stereo"] is True + ] + + # Get the stereocentres with 4 bonds as ~ chiral centres + chiral_centres = [ + centre + for centre in stereocentres + if len(list(species.graph.neighbors(centre))) == 4 + ] + + # Add distance constraints from one atom to the other 3 atoms to fix the + # configuration + for chiral_centre in chiral_centres: + neighbors = list(species.graph.neighbors(chiral_centre)) + atom_i = neighbors[0] + + for atom_j in neighbors[1:]: + dist_consts[(atom_i, atom_j)] = species.distance(atom_i, atom_j) + + # Check on every pair of stereocenters + for atom_i, atom_j in combinations(stereocentres, 2): + # If they are not bonded don't alter + if (atom_i, atom_j) not in species.graph.edges: + continue + + # Add a single distance constraint between the nearest neighbours of + # each stereocentre + for i_neighbour in species.graph.neighbors(atom_i): + for j_neighbour in species.graph.neighbors(atom_j): + if i_neighbour != atom_j and j_neighbour != atom_i: + # Fix the distance to the current value + dist = species.distance(i_neighbour, j_neighbour) + dist_consts[(i_neighbour, j_neighbour)] = dist + + logger.info(f"Have {len(dist_consts)} distance constraint(s)") + return dist_consts + + +def _get_non_random_atoms(species): + """ + Get the atoms that won't be randomised in the conformer generation. + Stereocentres and nearest neighbours + + --------------------------------------------------------------------------- + Arguments: + species (autode.species.Species): + + Returns: + (set(int)): Atoms indexes to not randomise + """ + stereocentres = [ + node + for node in species.graph.nodes + if species.graph.nodes[node]["stereo"] is True + ] + + non_rand_atoms = deepcopy(stereocentres) + for stereocentre in stereocentres: + non_rand_atoms += list(species.graph.neighbors(stereocentre)) + + if len(non_rand_atoms) > 0: + logger.info(f"Not randomising atom index(es) {set(non_rand_atoms)}") + + return np.array(list(set(non_rand_atoms)), dtype=int) + + +def _get_atoms_from_generated_file(species, xyz_filename): + """ + Get atoms from a previously generated .xyz file, if the atoms match + + --------------------------------------------------------------------------- + Arguments: + species (autode.species.Species): + xyz_filename (str): + + Returns: + (list(autode.atoms.Atoms)) or None: Atoms from file + """ + + if not os.path.exists(xyz_filename): + return None + + atoms = xyz_file_to_atoms(filename=xyz_filename) + + if len(atoms) != species.n_atoms: + return None + + all_atoms_match = all( + atoms[i].label == species.atoms[i].label + for i in range(species.n_atoms) + ) + + if all_atoms_match: + logger.info("Conformer has already been generated") + return atoms + + return None + + +def _get_coords_no_init_structure(atoms, species, d0, constrained_bonds): + """ + Generate coordinates where no initial structure is present - this fixes(?) + a problem for large molecule where if all the atoms are initially bonded + and minimised then high energy minima are often found + + Args: + atoms (list(autode.atoms.Atom)): + species (autode.species.Species): + d0 (np.ndarray): + constrained_bonds (list): + + Returns: + (np.ndarray): Optimised coordinates, shape = (n_atoms, 3) + """ + # Minimise atoms with no bonds between them + far_coords, _ = _get_coords_energy( + coords=np.array([atom.coord for atom in atoms]), + bonds=species.graph.edges, + fixed_bonds=constrained_bonds, + k=0.0, + c=0.1, + d0=d0, + tol=5e-3, + exponent=2, + ) + coords = far_coords[:2] + + # Add the atoms one by one to the structure. Thanks to Dr. Cyrille Lavigne + # for this suggestion! + for n in range(2, species.n_atoms): + new_coords = np.concatenate((coords, far_coords[len(coords) : n + 1])) + coords, _ = _get_coords_energy( + new_coords, + bonds=species.graph.edges, + fixed_bonds=constrained_bonds, + k=0.1, + c=0.1, + d0=d0, + tol=1e-3, + exponent=2, + ) + + # Perform a final minimisation + coords, energy = _get_coords_energy( + coords=coords, + bonds=species.graph.edges, + fixed_bonds=constrained_bonds, + k=1.0, + c=0.01, + d0=d0, + tol=1e-5, + ) + return coords, energy + + +@log_time(prefix="Generated RR atoms in:", units="s") +def get_simanl_atoms( + species: "Species", + dist_consts: Optional[Dict] = None, + conf_n: int = 0, + save_xyz: bool = True, + also_return_energy: bool = False, +): + r""" + Use a bonded + repulsive force field to generate 3D structure for a + species. If the initial coordinates are reasonable e.g. from a previously + generated 3D structure then add random displacement vectors and minimise + to generate a conformer. Otherwise add atoms to the box sequentially + until all atoms have been added, which generates a qualitatively reasonable + 3D geometry which should be optimised using a electronic structure method:: + + V(x) = Σ_bonds k(d - d0)^2 + Σ_ij c/d^n + + --------------------------------------------------------------------------- + Arguments: + species (autode.species.Species): + + dist_consts (dict): Key = tuple of atom indexes, Value = distance + + conf_n (int): Number of this conformer + + save_xyz (bool): Whether or not to save a .xyz file of the structure + for fast reloading + + also_return_energy (bool): Whether or not to return the energy + + Returns: + (list(autode.atoms.Atom)): Atoms + """ + xyz_filename = f"{species.name}_conf{conf_n}_siman.xyz" + + saved_atoms = _get_atoms_from_generated_file(species, xyz_filename) + if saved_atoms is not None and not also_return_energy: + return saved_atoms + + # To generate the potential requires bonds between atoms defined in a + # molecular graph + if species.graph is None: + raise ex.NoMolecularGraph + + # Initialise a new random seed and make a copy of the species' atoms. + # RandomState is thread safe + rand = np.random.RandomState() + atoms = _get_atoms_rotated_stereocentres( + species=species, atoms=deepcopy(species.atoms), rand=rand + ) + + # Add the distance constraints as fixed bonds + d0 = species.graph.eqm_bond_distance_matrix + + # Add distance constraints across stereocentres e.g. for a Z double bond + # then modify d0 appropriately + curr_dist_consts = {} if dist_consts is None else dist_consts + distance_constraints = _add_dist_consts_for_stereocentres( + species=species, dist_consts=curr_dist_consts + ) + + constrained_bonds = [] + for bond, length in distance_constraints.items(): + i, j = bond + d0[i, j] = length + d0[j, i] = length + constrained_bonds.append(bond) + + # Randomise coordinates that aren't fixed by shifting a maximum of + # autode.Config.max_atom_displacement in x, y, z + fixed_atom_indexes = _get_non_random_atoms(species=species) + + # Shift by a factor defined in the config file if the coordinates are + # reasonable but otherwise init in a 10 A cube + reasonable_init_coords = ade.geom.are_coords_reasonable( + species.coordinates + ) + + if reasonable_init_coords: + factor = ade.Config.max_atom_displacement / np.sqrt(3) + for i, atom in enumerate(atoms): + if i not in fixed_atom_indexes: + atom.translate(vec=factor * rand.uniform(-1, 1, 3)) + else: + # Randomise in a 10 Å cubic box + [atom.translate(vec=rand.uniform(-5, 5, 3)) for atom in atoms] + + if reasonable_init_coords: + init_coords = np.array([atom.coord for atom in atoms]) + coords, energy = _get_coords_energy( + coords=init_coords, + bonds=species.graph.edges, + k=1.0, + c=0.01, + d0=d0, + tol=1e-5, + fixed_idxs=fixed_atom_indexes, + fixed_bonds=constrained_bonds, + ) + else: + coords, energy = _get_coords_no_init_structure( + atoms, species, d0, constrained_bonds + ) + + # Set the coordinates of the new atoms + for i, atom in enumerate(atoms): + atom.coord = coords[i] + + # Print an xyz file so rerunning will read the file + if save_xyz: + atoms_to_xyz_file(atoms=atoms, filename=xyz_filename) + + if also_return_energy: + logger.info(f"E_RR = {energy:.6f}") + return atoms, energy + + return atoms + + +def get_simanl_conformer( + species: "Species", + dist_consts: Optional[Dict] = None, + conf_n: int = 0, + save_xyz: bool = True, +) -> "Conformer": + """ + Generate a conformer of a species using randomise+relax with a simple FF + (see get_simanl_atoms). Example + + .. code-block:: Python + >>> import autode as ade + >>> from autode.conformers.conf_gen import get_simanl_conformer + >>> mol = ade.Molecule(smiles='CCCC', name='butane') + >>> conf0 = get_simanl_conformer(mol, conf_n=0, save_xyz=False) + Conformer(butane_conf0, n_atoms=14, charge=0, mult=1) + + --------------------------------------------------------------------------- + Arguments: + species (autode.species.Species): + + dist_consts (dict): Key = tuple of atom indexes, Value = distance + + conf_n (int): Number of this conformer + + save_xyz (bool): Whether or not to save a .xyz file of the structure + + Returns: + (autode.conformers.Conformer): Conformer + """ + + conformer = Conformer( + species=species, + name=f"{species.name}_conf{conf_n}", + dist_consts=dist_consts, + ) + + atoms, energy = get_simanl_atoms( + species, + dist_consts=dist_consts, + conf_n=conf_n, + save_xyz=save_xyz, + also_return_energy=True, + ) + conformer.atoms = atoms + conformer.energy = energy + + return conformer diff --git a/autodE/source/autode/conformers/conformer.py b/autodE/source/autode/conformers/conformer.py new file mode 100644 index 0000000000000000000000000000000000000000..f90b5755378fa1b9c53ad2222a9cf3012caa6846 --- /dev/null +++ b/autodE/source/autode/conformers/conformer.py @@ -0,0 +1,184 @@ +import numpy as np + +from typing import Optional, TYPE_CHECKING + +from autode.atoms import Atoms +from autode.values import Coordinates +from autode.exceptions import AtomsNotFound +from autode.log import logger +from autode.species.species import Species + +if TYPE_CHECKING: + from autode.calculations.calculation import Calculation + from autode.wrappers.methods import Method + from autode.wrappers.keywords import Keywords + + +class Conformer(Species): + def __init__( + self, + name: str = "conf", + atoms: Optional["Atoms"] = None, + solvent_name: Optional[str] = None, + charge: int = 0, + mult: int = 1, + dist_consts: Optional[dict] = None, + species: Optional[Species] = None, + ): + """ + Construct a conformer either using the standard species constructor, + or from a species directly. + + ----------------------------------------------------------------------- + See Also: + (autode.species.species.Species): + """ + super().__init__(name, atoms, charge, mult, solvent_name=solvent_name) + self._parent_atoms = None + self._coordinates = None + + if species is not None: + self._parent_atoms = species.atoms + self._coordinates = species.coordinates.copy() + self.charge = species.charge # Require identical charge/mult/solv + self.mult = species.mult + self.solvent = species.solvent + + if atoms is not None: # Specified atoms overrides species + self.atoms = Atoms(atoms) + + self.constraints.update(distance=dist_consts) + + def __repr__(self): + """Representation of a conformer""" + return self._repr(prefix="Conformer") + + def __eq__(self, other): + return super().__eq__(other) + + def single_point( + self, + method: "Method", + keywords: Optional["Keywords"] = None, + n_cores: Optional[int] = None, + ): + """ + Calculate a single point and default to a low level single point method + + ---------------------------------------------------------------------- + Arguments: + method (autode.wrappers.base.ElectronicStructureMethod): + + keywords (autode.wrappers.keywords.Keywords): + + n_cores (int | None): If None then defaults to Config.n_cores + """ + keywords = method.keywords.low_sp if keywords is None else keywords + + return super().single_point(method, keywords, n_cores=n_cores) + + def optimise( + self, + method: Optional["Method"] = None, + reset_graph: bool = False, + calc: Optional["Calculation"] = None, + keywords: Optional["Keywords"] = None, + n_cores: Optional[int] = None, + ): + """ + Optimise the geometry of this conformer using a method. Will use + low_opt keywords if no keywords are given. + + ----------------------------------------------------------------------- + Arguments: + method (autode.wrappers.base.ElectronicStructureMethod): + + reset_graph (bool): + + calc (autode.calculation.Calculation): + + keywords (autode.wrappers.keywords.Keywords): + + n_cores (int | None): If None then defaults to Config.n_cores + """ + try: + if keywords is None and method is not None: + keywords = method.keywords.low_opt + + super().optimise( + method, keywords=keywords, calc=calc, n_cores=n_cores + ) + + except AtomsNotFound: + logger.error(f"Atoms not found for {self.name} but not critical") + self.atoms = None + + return None + + @property + def coordinates(self) -> Optional[Coordinates]: + """Coordinates of this conformer""" + return self._coordinates + + @coordinates.setter + def coordinates(self, value: np.ndarray): + """Set the coordinates of this conformer""" + if self._parent_atoms is None: + raise ValueError( + "Conformer has no parent atoms. Setting the " + "coordinates will leave the atoms undefined" + ) + + self._coordinates = Coordinates(value) + + @property + def atoms(self) -> Optional[Atoms]: + """ + Atoms of this conformer are built from the parent atoms and the + coordinates that are unique to this conformer. + """ + + if self._parent_atoms is None or self._coordinates is None: + return None + + atoms = Atoms() + for parent_atom, coord in zip(self._parent_atoms, self._coordinates): + atom = parent_atom.copy() + atom.coord = coord + + atoms.append(atom) + + return atoms + + @atoms.setter + def atoms(self, value: Optional[Atoms]): + """ + Set the atoms of this conformer. + + If None then set the corresponding coordinates of this conformer to + None (such that self.atoms is None). If this conformer has coordinates + then set those from the individual atomic coordinates otherwise + set the coordinates as a batch + """ + + if value is None: # Clear the coordinates + self._coordinates = None + return + + if self._parent_atoms is None: + self._parent_atoms = value + + if self._coordinates is None: + self._coordinates = value.coordinates + return + + for i, atom in enumerate(value): + parent_atom = self._parent_atoms[i] + if atom.label != parent_atom.label: + raise ValueError( + "Cannot alter the atomic symbols of a " + "conformer. Parent molecule was different: " + f"{atom.label} != {parent_atom.label}" + ) + + self._coordinates[i] = atom.coord.copy() diff --git a/autodE/source/autode/conformers/conformers.py b/autodE/source/autode/conformers/conformers.py new file mode 100644 index 0000000000000000000000000000000000000000..624cca4f20a9b8c478570c2f95823fac888232f2 --- /dev/null +++ b/autodE/source/autode/conformers/conformers.py @@ -0,0 +1,360 @@ +import numpy as np + +from typing import Optional, Union, TYPE_CHECKING +from rdkit import Chem + +from autode.values import Distance, Energy +from autode.atoms import Atom, Atoms +from autode.config import Config +from autode.mol_graphs import make_graph, is_isomorphic +from autode.geom import calc_heavy_atom_rmsd +from autode.log import logger +from autode.utils import ProcessPool +from autode.exceptions import NoConformers, CouldNotGetProperty + + +if TYPE_CHECKING: + from autode.conformers.conformer import Conformer + from autode.wrappers.methods import Method + from autode.mol_graphs import MolecularGraph + from autode.wrappers.keywords import Keywords + + +def _calc_conformer(conformer, calc_type, method, keywords, n_cores=1): + """Top-level hashable function to call in parallel""" + func = getattr(conformer, calc_type) + try: + func(method=method, keywords=keywords, n_cores=n_cores) + except CouldNotGetProperty as e: + logger.warning( + f"Failed to run calculation on conformer {conformer.name} due to {e}" + ) + + return conformer + + +class Conformers(list): + @property + def lowest_energy(self) -> Optional["Conformer"]: + """ + Return the lowest energy conformer state from this set. If no + conformers have an energy then return None + + ----------------------------------------------------------------------- + Returns: + (autode.conformers.Conformer | None): Conformer + """ + if all(c.energy is None for c in self): + logger.error("Have no conformers with an energy, so no lowest") + return None + + energies = [c.energy if c.energy is not None else np.inf for c in self] + return self[np.argmin(energies)] + + def prune( + self, + e_tol: Union[Energy, float] = Energy(1.0, "kJ mol-1"), + rmsd_tol: Union[Distance, float, None] = None, + n_sigma: float = 5, + remove_no_energy: bool = False, + ) -> None: + """ + Prune conformers based on both energy and root mean squared deviation + (RMSD) values. Will discard any conformers that are within e_tol in + energy (Ha) and rmsd in RMSD (Å) to any other + + ----------------------------------------------------------------------- + Arguments: + e_tol (Energy): Energy tolerance + + rmsd_tol (Distance | None): RMSD tolerance. Defaults to + autode.Config.rmsd_threshold + + n_sigma (float | int): + + remove_no_energy (bool): + """ + + if remove_no_energy: + self.remove_no_energy() + + self.prune_on_energy(e_tol=e_tol, n_sigma=n_sigma) + self.prune_on_rmsd(rmsd_tol=rmsd_tol) + + return None + + def prune_on_energy( + self, + e_tol: Union[Energy, float] = Energy(1.0, "kJ mol-1"), + n_sigma: float = 5, + ) -> None: + """ + Prune the conformers based on an energy threshold, discarding those + that have energies that are similar to within e_tol. Also discards + conformers with very high energies (indicating a problem + with the calculation) if the are more than n_sigma standard deviations + away from the mean + + ----------------------------------------------------------------------- + Arguments: + e_tol (autode.values.Energy | float | None): + + n_sigma (int): Number of standard deviations a conformer energy + must be from the average for it not to be added + """ + idxs_with_energy = [ + idx for idx, conf in enumerate(self) if conf.energy is not None + ] + n_prev_confs = len(self) + + if len(idxs_with_energy) < 2: + logger.info( + f"Only have {len(self)} conformers with an energy. No " + f"need to prune" + ) + return None + + energies = [self[idx].energy for idx in idxs_with_energy] + + # Use a lower-bounded σ to prevent division by zero + std_dev_e = max(float(np.std(energies)), 1e-8) + avg_e = np.average(energies) + + logger.info( + f"Have {len(energies)} energies with μ={avg_e:.6f} Ha " + f"σ={std_dev_e:.6f} Ha" + ) + + if isinstance(e_tol, Energy): + e_tol = float(e_tol.to("Ha")) + else: + logger.warning( + f"Assuming energy tolerance {e_tol:.6f} has units " f"of Ha" + ) + + # Delete from the end of the list to preserve the order when deleting + for i, idx in enumerate(reversed(idxs_with_energy)): + conf = self[idx] + idxs_with_energy = [j for j in idxs_with_energy if j < len(self)] + + if np.abs(conf.energy - avg_e) / std_dev_e > n_sigma: + logger.warning( + f"Conformer {idx} had an energy >{n_sigma}σ " + f"from the average - removing" + ) + del self[idx] + continue + + if i == 0: + # The first (last) conformer must be unique + continue + + if any( + np.abs(conf.energy - self[o_idx].energy) < e_tol + for o_idx in idxs_with_energy + if o_idx != idx + ): + logger.info(f"Conformer {idx} had a non unique energy") + del self[idx] + continue + + logger.info( + f"Stripped {n_prev_confs - len(self)} conformer(s)." + f" {n_prev_confs} -> {len(self)}" + ) + return None + + def prune_on_rmsd( + self, rmsd_tol: Union[Distance, float, None] = None + ) -> None: + """ + Given a list of conformers add those that are unique based on an RMSD + tolerance. If rmsd=None then use autode.Config.rmsd_threshold + + ----------------------------------------------------------------------- + Arguments: + rmsd_tol (autode.values.Distance | float | None): + """ + if len(self) < 2: + logger.info( + f"Only have {len(self)} conformers. No need to prune " + f"on RMSD" + ) + return None + + rmsd_tol = Config.rmsd_threshold if rmsd_tol is None else rmsd_tol + + if isinstance(rmsd_tol, float): + logger.warning( + f"Assuming RMSD tolerance {rmsd_tol:.2f} has units" f" of Å" + ) + rmsd_tol = Distance(rmsd_tol, "Å") + + logger.info( + f'Removing conformers with RMSD < {rmsd_tol.to("ang")} Å ' + f"to any other (heavy atoms only, with no symmetry)" + ) + + # Only enumerate up to but not including the final index, as at + # least one of the conformers must be unique in geometry + for idx in reversed(range(len(self) - 1)): + conf = self[idx] + + if any( + calc_heavy_atom_rmsd(conf.atoms, other.atoms) < rmsd_tol + for o_idx, other in enumerate(self) + if o_idx != idx + ): + logger.info( + f"Conformer {idx} was close in geometry to at " + f"least one other - removing" + ) + + del self[idx] + + logger.info(f"Pruned to {len(self)} unique conformer(s) on RMSD") + return None + + def prune_diff_graph(self, graph: "MolecularGraph") -> None: + """ + Remove conformers with a different molecular graph to a defined + reference. Although all conformers should have the same molecular + graph there are situations where not pruning these is useful + + ----------------------------------------------------------------------- + + Arguments: + graph: Reference graph + """ + n_prev_confs = len(self) + + for idx in reversed(range(len(self))): + conformer = self[idx] + make_graph(conformer) + + if not is_isomorphic( + conformer.graph, graph, ignore_active_bonds=True + ): + logger.warning("Conformer had a different graph. Ignoring") + del self[idx] + + logger.info(f"Pruned on connectivity {n_prev_confs} -> {len(self)}") + return None + + def remove_no_energy(self) -> None: + """Remove all conformers from this list that do not have an energy""" + n_conformers_before_remove = len(self) + + for idx in reversed(range(len(self))): # Enumerate backwards + if self[idx].energy is None: + del self[idx] + + n_conformers = len(self) + if n_conformers == 0 and n_conformers != n_conformers_before_remove: + raise NoConformers( + f"Removed all the conformers " + f"{n_conformers_before_remove} -> 0" + ) + + def _parallel_calc(self, calc_type, method, keywords): + """ + Run a set of calculations (single point energy evaluations or geometry + optimisations) in parallel over every conformer in this set. Will + attempt to use all autode.Config.n_cores as fully as possible + + Arguments: + calc_type (str): + + method (autode.wrappers.base.ElectronicStructureMethod): + + keywords (autode.wrappers.keywords.Keywords): + """ + # TODO: Test efficiency + improve with dynamic load balancing + if len(self) == 0: + logger.error(f"Cannot run {calc_type} over 0 conformers") + return None + + n_cores_pp = max(Config.n_cores // len(self), 1) + + with ProcessPool(max_workers=Config.n_cores // n_cores_pp) as pool: + jobs = [ + pool.submit( + _calc_conformer, + conf, + calc_type, + method, + keywords, + n_cores=n_cores_pp, + ) + for conf in self + ] + + for idx, res in enumerate(jobs): + self[idx] = res.result() + + return None + + def optimise( + self, + method: "Method", + keywords: Optional["Keywords"] = None, + ) -> None: + """ + Optimise a set of conformers in parallel + + ----------------------------------------------------------------------- + Arguments: + method (autode.wrappers.base.ElectronicStructureMethod): + + keywords (autode.wrappers.keywords.Keywords): + """ + return self._parallel_calc("optimise", method, keywords) + + def single_point( + self, + method: "Method", + keywords: Optional["Keywords"] = None, + ) -> None: + """ + Evaluate single point energies for a set of conformers in parallel + + ----------------------------------------------------------------------- + Arguments: + method (autode.wrappers.base.ElectronicStructureMethod): + + keywords (autode.wrappers.keywords.Keywords): + """ + return self._parallel_calc("single_point", method, keywords) + + def copy(self) -> "Conformers": + return Conformers([conformer.copy() for conformer in self]) + + +def atoms_from_rdkit_mol(rdkit_mol_obj: Chem.Mol, conf_id: int = 0) -> Atoms: + """ + Generate atoms for a conformer contained within an RDKit molecule object + + --------------------------------------------------------------------------- + Arguments: + rdkit_mol_obj (rdkit.Chem.Mol): RDKit molecule + + conf_id (int): Conformer id to convert to atoms + + Returns: + (list(autode.atoms.Atom)): Atoms + """ + + mol_block_lines = Chem.MolToMolBlock(rdkit_mol_obj, confId=conf_id).split( + "\n" + ) + mol_file_atoms = Atoms() + + # Extract atoms from the mol block + for line in mol_block_lines: + split_line = line.split() + + if len(split_line) == 16: + x, y, z, atom_label = split_line[:4] + mol_file_atoms.append(Atom(atom_label, x=x, y=y, z=z)) + + return mol_file_atoms diff --git a/autodE/source/autode/constants.py b/autodE/source/autode/constants.py new file mode 100644 index 0000000000000000000000000000000000000000..2b2281802f2ed620520cfb25699e1da969bd94e7 --- /dev/null +++ b/autodE/source/autode/constants.py @@ -0,0 +1,30 @@ +class Constants: + n_a = 6.022140857e23 # molecules mol-1 + + ha_to_kcalmol = ha2kcalmol = 627.509 # Hartree^-1 kcal mol^-1 + ha_to_kJmol = ha2kJmol = 2625.50 # Hartree^-1 kJ mol^-1 + + ha_to_J = ha_to_kJmol * 1000 / n_a # Hartree^-1 J + J_to_ha = 1.0 / ha_to_J # J Hartree^-1 + + eV_to_ha = eV2ha = 0.0367493 # Hartree eV^-1 + ha_to_eV = ha2eV = 1.0 / eV_to_ha # eV Hartree^-1 + + kcal_to_kJ = kcal2kJ = 4.184 # kJ kcal^-1 + + rad_to_deg = 57.29577951308232087679815 # deg rad^-1 + + a0_to_ang = a02ang = 0.529177 # Å bohr^-1 + ang_to_a0 = ang2a0 = 1.0 / a0_to_ang # bohr Å^-1 + ang_to_nm = 0.1 # nm ang^-1 + ang_to_pm = 100 # pm ang^-1 + ang_to_m = 1e-10 # m ang^-1 + a0_to_m = a0_to_ang * ang_to_m # Å m^-1 + + per_cm_to_hz = c_in_cm = 299792458 * 100 # cm s^-1 + + amu_to_kg = 1.66053906660e-27 # kg amu^-1 + amu_to_me = 1822.888486209 # m_e amu^-1 + + atm_to_pa = 101325 # Pa atm^-1 + dm_to_m = 0.1 # m dm^-1 diff --git a/autodE/source/autode/constraints.py b/autodE/source/autode/constraints.py new file mode 100644 index 0000000000000000000000000000000000000000..2d58430c0b025c5c59ccabf0833c373c6c6177e7 --- /dev/null +++ b/autodE/source/autode/constraints.py @@ -0,0 +1,188 @@ +from collections.abc import MutableMapping +from typing import Optional, Dict, List +from copy import deepcopy + +from autode.values import Distance +from autode.log import logger + + +class Constraints: + def __init__( + self, distance: Optional[Dict] = None, cartesian: Optional[List] = None + ): + """ + Constrained distances and positions + + ----------------------------------------------------------------------- + Arguments: + distance (dict | None): Keys of: tuple(int) for two atom indexes + and values of the distance in Å, or None + + cartesian (list(int) | None): List of atom indexes or None + """ + self._distance = DistanceConstraints() + self._cartesian: List[int] = [] + + self.update(distance, cartesian) + + def __str__(self): + """String of constraints""" + string = "" + + if self.cartesian is not None: + string += str(self.cartesian) + + if self.distance is not None: + string += str( + {key: round(val, 3) for key, val in self.distance.items()} + ) + + return f"Constraints({string})" + + def __repr__(self): + return self.__str__() + + @property + def distance(self) -> Optional["DistanceConstraints"]: + return None if len(self._distance) == 0 else self._distance + + @distance.setter + def distance(self, value: Optional[dict]): + """ + Set the distance constraints + + ----------------------------------------------------------------------- + Arguments: + value (dict | None): Dictionary keyed with atom indexes with values + as the distance between the two + """ + + if value is None: + self._distance.clear() + + else: + self._distance = DistanceConstraints(value) + + @property + def n_distance(self) -> int: + """Number of distance constraints""" + return len(self._distance) + + @property + def cartesian(self) -> Optional[list]: + """Cartesian constraints""" + return ( + None if len(self._cartesian) == 0 else list(set(self._cartesian)) + ) + + @cartesian.setter + def cartesian(self, value: Optional[List[int]]): + """ + Set the Cartesian constraints using a list of atom indexes + + ----------------------------------------------------------------------- + Arguments: + value (list(int) | None): Atom indexes to fix in space + """ + if value is None: + self._cartesian.clear() + + else: + self._cartesian = [int(i) for i in value] + + @property + def n_cartesian(self) -> int: + """Number of distance constraints""" + return len(self._cartesian) + + @property + def any(self) -> bool: + """Are there any constraints?""" + return self.distance is not None or self.cartesian is not None + + def update( + self, + distance: Optional[dict] = None, + cartesian: Optional[List[int]] = None, + ) -> None: + """ + Update the current set of constraints with a new distance and or + Cartesian set + + ----------------------------------------------------------------------- + Arguments: + distance (dict): + + cartesian (list): + """ + + if distance is not None: + self._distance.update(DistanceConstraints(distance)) + + if cartesian is not None: + self._cartesian += cartesian + + return None + + def copy(self) -> "Constraints": + return deepcopy(self) + + +class DistanceConstraints(MutableMapping): + def __init__(self, *args, **kwargs): + self._store = dict() + self.update(dict(*args, **kwargs)) # use the free update to set keys + + def __getitem__(self, key): + return self._store[self._key_transform(key)] + + def __delitem__(self, key): + del self._store[self._key_transform(key)] + + def __iter__(self): + return iter(self._store) + + def __len__(self): + return len(self._store) + + @staticmethod + def _key_transform(key): + """Transform the key to a sorted tuple""" + return tuple(sorted(key)) + + def __setitem__(self, key, value): + """ + Set a key-value pair in the dictionary + + ----------------------------------------------------------------------- + Arguments: + key (tuple(int)): Pair of atom indexes + + value (int | float): Distance + """ + try: + n_unique_atoms = len(set(key)) + + except TypeError: + raise ValueError(f"Cannot set a key with {key}, must be iterable") + + if n_unique_atoms != 2: + logger.error( + "Tried to set a distance constraint with a key: " + f"{key}. Must be a unique pair of atom indexes" + ) + return + + if float(value) <= 0: + raise ValueError("Negative distances are not valid constraints!") + + if any(int(atom_idx) < 0 for atom_idx in key): + raise ValueError( + "Distance constraint key must be an atom index " + f"pair but had: {key} which cannot be valid (<0)" + ) + + self._store[self._key_transform(key)] = Distance(value) + + def copy(self) -> "DistanceConstraints": + return deepcopy(self) diff --git a/autodE/source/autode/exceptions.py b/autodE/source/autode/exceptions.py new file mode 100644 index 0000000000000000000000000000000000000000..5dc001f27b51dd5b186e8a333c009d104425da1b --- /dev/null +++ b/autodE/source/autode/exceptions.py @@ -0,0 +1,138 @@ +class AutodeException(Exception): + """Base autodE exception""" + + +# --------------------- Calculation exceptions -------------------------------- +class CalculationException(AutodeException): + """Base calculation exception when an external autodE calculation fails""" + + +class AtomsNotFound(CalculationException): + """Exception for atomic coordinates/identities being found""" + + +class MethodUnavailable(CalculationException): + """Exception for an autodE wrapped method not being available""" + + +class UnsupportedCalculationInput(CalculationException): + """Exception for an autodE calculation input not being valid""" + + def __init__(self, message="Parameters not supported"): + super().__init__(message) + + +class NoInputError(CalculationException): + """Exception for a autodE calculation having no input e.g atoms""" + + +class NoCalculationOutput(CalculationException): + """Exception for a calculation file that should exist not existing""" + + +class CouldNotGetProperty(CalculationException): + """Exception for where a property e.g. energy cannot be found in a + calculation output file""" + + def __init__(self, *args, name=None): + if name is not None: + super().__init__(f"Could not get {name}") + + else: + super().__init__(*args) + + +class NotImplementedInMethod(CalculationException): + """Exception for where a method is not implemented in a wrapped method""" + + +# ----------------------------------------------------------------------------- +class NoAtomsInMolecule(AutodeException): + """Exception for no atoms existing in a molecule""" + + +class NoConformers(AutodeException): + """Exception for no conformers being present in a molecule""" + + +class SolventUnavailable(AutodeException): + """Exception for a solvent not being available for a calculation""" + + +class UnbalancedReaction(AutodeException): + """Exception for an autodE reaction not being balanced in e.g. N_atoms""" + + +class SolventNotFound(AutodeException): + """Exception for a solvent not being in the list of available solvents""" + + +class SolventsDontMatch(AutodeException): + """Exception for where the solvent in reactants is different to products""" + + +class BondsInSMILESAndGraphDontMatch(AutodeException): + """Exception for a partially disjoint set of bonds in SMILES and a graph""" + + +class XYZfileDidNotExist(AutodeException, FileNotFoundError): + """Exception for an xyz file not existing""" + + +class XYZfileWrongFormat(AutodeException): + """Exception for an xyz being the wrong format""" + + +class ReactionFormationFailed(AutodeException): + """Exception for where a reaction cannot be built. e.g. if there are no + reactants""" + + +class NoMapping(AutodeException): + """Exception for where there is no mapping/bijection between two graphs""" + + +class NoMolecularGraph(AutodeException): + """Exception for a molecule not having a set graph""" + + +class RDKitFailed(AutodeException): + """Exception for where RDKit fails to generate conformers""" + + +class InvalidSmilesString(AutodeException): + """Exception for a SMILES string being invalid""" + + +class SMILESBuildFailed(AutodeException): + """Exception for where building an example 3D structure cannot be built + from a SMILES string""" + + +class FailedToSetRotationIdxs(AutodeException): + """Exception for the atoms that need to be rotating in 3D building not + being found""" + + +class FailedToAdjustAngles(AutodeException): + """Exception for when the internal angles in a ring cannot be adjusted""" + + +class CannotSplitAcrossBond(AutodeException): + """A molecule cannot be partitioned by deleting one bond""" + + +class CouldNotPlotSmoothProfile(AutodeException): + """A smooth reaction profile cannot be plotted""" + + +class TemplateLoadingFailed(AutodeException): + """A template file was not in the correct format""" + + +class OptimiserStepError(AutodeException): + """Unable to calculate a valid Optimiser step""" + + +class CoordinateTransformFailed(AutodeException): + """Internal coordinate to Cartesian transform failed""" diff --git a/autodE/source/autode/ext/CMakeLists.txt b/autodE/source/autode/ext/CMakeLists.txt new file mode 100644 index 0000000000000000000000000000000000000000..990598d6527a2e757ba3be877ab25354e08fcc0c --- /dev/null +++ b/autodE/source/autode/ext/CMakeLists.txt @@ -0,0 +1,36 @@ +cmake_minimum_required(VERSION 3.16) +project(ade_ext) + +set(CMAKE_CXX_STANDARD 11) +include_directories(include/) + +add_library(autode STATIC + include/dihedrals.h src/dihedrals.cpp + include/molecule.h src/molecule.cpp + include/optimisers.h src/optimisers.cpp + include/points.h src/points.cpp + include/potentials.h src/potentials.cpp + include/utils.h src/utils.cpp + ) + +Include(FetchContent) + +FetchContent_Declare( + Catch2 + GIT_REPOSITORY https://github.com/catchorg/Catch2.git + GIT_TAG v2.13.7 +) + +FetchContent_MakeAvailable(Catch2) + +enable_testing() + +add_executable(unit_tests + tests/test_main.cpp + tests/test_point_gen.cpp + tests/test_global_dihedral_min.cpp) + +target_link_libraries(unit_tests PRIVATE Catch2::Catch2) +target_link_libraries(unit_tests PUBLIC autode) + +add_test(test_all unit_tests) diff --git a/autodE/source/autode/ext/README.md b/autodE/source/autode/ext/README.md new file mode 100644 index 0000000000000000000000000000000000000000..8065b8e22eb5abe08cf2fe036e573aac62fc34e6 --- /dev/null +++ b/autodE/source/autode/ext/README.md @@ -0,0 +1,14 @@ +## autodE C++ extensions + +Some **autodE** functionality is written in C++ for speed +and the functionality exposed to Python using Cython wrappers around +the base classes. + +Currently it's written in C++11 for compatibility with older compilers. Build and +run the tests by, in this directory: + +```bash +cmake . && make -j2 && ./unit_tests +``` + +*assuming 2 cores are available*. \ No newline at end of file diff --git a/autodE/source/autode/ext/__init__.py b/autodE/source/autode/ext/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/autodE/source/autode/ext/ade_dihedrals.pyx b/autodE/source/autode/ext/ade_dihedrals.pyx new file mode 100644 index 0000000000000000000000000000000000000000..2d238b43b9dcdd6d6c904d72ec6bb8e254fd1eb8 --- /dev/null +++ b/autodE/source/autode/ext/ade_dihedrals.pyx @@ -0,0 +1,200 @@ +# distutils: language = c++ +# distutils: sources = [autode/ext/src/dihedrals.cpp, autode/ext/src/molecule.cpp, autode/ext/src/optimisers.cpp, autode/ext/src/potentials.cpp, autode/ext/src/utils.cpp, autode/ext/src/points.cpp] +import numpy as np +from autode.ext.wrappers cimport (Molecule, + Dihedral, + RDihedralPotential, + RRingDihedralPotential, + SDDihedralOptimiser, + GridDihedralOptimiser, + SGlobalDihedralOptimiser) + + +cdef Molecule molecule_with_dihedrals(py_coords, + py_axes, + py_rot_idxs, + py_origins, + py_angles=None): + """Generate a autode::Molecule with defined dihedral angles""" + + cdef Molecule molecule + cdef Dihedral dihedral + + molecule = Molecule(py_coords.flatten()) + + # Set a dihedral for each axis, with perhaps a defined angle + for i in range(py_axes.shape[0]): + dihedral = Dihedral(0 if py_angles is None else py_angles[i], + np.asarray(py_axes[i], dtype='i4'), + np.asarray(py_rot_idxs[i], dtype=bool), + py_origins[i]) + + molecule._dihedrals.push_back(dihedral) + + return molecule + + +def rotate(py_coords, + py_angles, + py_axes, + py_rot_idxs, + py_origins, + rep_exponent=2, + minimise=False, + py_rep_idxs=None): + """ + Rotate coordinates by a set of dihedral angles each around an axis placed + at an origin + + -------------------------------------------------------------------------- + Arguments: + py_coords (np.ndarray): shape = (n_atoms, 3) Atomic coordinates in 3D + + py_angles (np.ndarray): shape = (m,) Angles in radians to rotate by + + py_axes (np.ndarray): shape = (m, 2) Atom indexes for the two atoms + defining the rotation axis, for each angle + + py_rot_idxs (np.ndarray): shape = (m, n_atoms) Bit array for each angle + with 1 if this atom should be rotated and 0 + otherwise + + py_origins (np.ndarray): shape = (m,) Atom indexes of the origin for + each rotation + + Keyword Arguments: + minimise (bool): Should the coordinates be minimised? + + py_rep_idxs (np.ndarray): shape = (n_atoms, n_atoms). Square boolean + matrix indexing the atoms which should be + considered to be pairwise repulsive. + *Only the upper triangular portion is used* + + Returns: + (np.ndarray): Rotated coordinates + """ + + cdef Molecule molecule = molecule_with_dihedrals(py_coords=py_coords, + py_axes=py_axes, + py_rot_idxs=py_rot_idxs, + py_origins=py_origins, + py_angles=py_angles) + molecule.rotate_dihedrals() + + cdef RDihedralPotential potential + cdef SDDihedralOptimiser optimiser + + # Consider all pairwise repulsions, as only the upper triangle is used + # the whole matrix can be True + if py_rep_idxs is None: + n_atoms = py_coords.shape[0] + py_rep_idxs = np.ones(shape=(n_atoms, n_atoms), dtype=bool) + + if minimise: + potential = RDihedralPotential(rep_exponent, # 1/r_ij^exponent + py_rep_idxs.flatten()) # Repulsive pairs + + optimiser.run(potential, + molecule, + 50, # Maximum number of iterations + 1E-5, # Tolerance on ∆E_k->k+1 for a step k + 1.0) # Initial step size (Å) + + py_coords = np.asarray(molecule.coords).reshape(-1, 3) + return py_coords + + +cpdef closed_ring_coords(py_coords, + py_curr_angles, + py_ideal_angles, + py_axes, + py_rot_idxs, + py_origins, + py_rep_idxs, + py_close_idxs, + py_r0=1.5, + py_rep_exponent=2): + """ + Close a ring by altering dihedral angles to minimise the pairwise repulsion + while also minimising the distance between the two atoms that will close + the ring with a harmonic term: V = (r_close - r0)^2 + + -------------------------------------------------------------------------- + Arguments: + py_coords (np.ndarray): shape = (n_atoms, 3) Atomic coordinates + + py_curr_angles (np.ndarray): shape = (m,) Current dihedral angles + + py_ideal_angles (list(float | None)): List of length m with the ideal + angles, if None then no ideal angle + + py_axes (np.ndarray): shape = (m, 2) Atom indexes for the axis atoms + + py_rot_idxs (np.ndarray): shape = (m, n_atoms) Bit array for each angle + with 1 if this atom should be rotated and 0 + otherwise + + py_origins (np.ndarray): shape = (m,) Atom indexes of the origin atoms + + py_rep_idxs (np.ndarray): shape = (n_atoms, n_atoms). Index pairs to + use for repulsion if {py_rep_idxs}_ij == 1 + + py_close_idxs (np.ndarray): shape = (2,) Pair of atoms that need to be + separated by approximately r0 + + Keyword Arguments: + + py_r0 (float): Optimal distance between the close indexes + + py_rep_exponent (int): Exponent to use for the repulsive part of the + potential + + Returns: + (np.ndarray): Rotated coordinates + """ + n_angles = py_curr_angles.shape[0] + + if n_angles == 0: + return py_coords # Nothing to rotate + + # For a completely defined set of ideal angles e.g. in a benzene ring + # then all there is to do is apply the rotations + if all([angle is not None for angle in py_ideal_angles]): + py_angles = [ideal - curr + for ideal, curr in zip(py_ideal_angles, py_curr_angles)] + + return rotate(py_coords=py_coords, + py_angles=py_angles, + py_axes=py_axes, + py_rot_idxs=py_rot_idxs, + py_origins=py_origins) + + # There are at least one dihedral to optimise on- + cdef Molecule molecule = molecule_with_dihedrals(py_coords, + py_axes, + py_rot_idxs, + py_origins) + cdef RRingDihedralPotential potential + cdef GridDihedralOptimiser grid_optimiser + cdef SGlobalDihedralOptimiser optimiser + + potential = RRingDihedralPotential(py_rep_exponent, # 1/r_ij^exponent + py_rep_idxs.flatten(), # Repulsive pairs + py_close_idxs, # Closing atoms + py_r0) # Distance + + if n_angles <= 3: + grid_optimiser.run(potential, + molecule, + 513, # Maximum number of grid points + 1E-5, # SD tolerance on ∆E_k->k+1 for a step k + 0.1) + else: + optimiser.run(potential, + molecule, + 10 * n_angles, # Number of total steps + 1E-5, # Final tolerance on SD minimisation + 0.1) + + py_coords = np.asarray(molecule.coords).reshape(-1, 3) + return py_coords diff --git a/autodE/source/autode/ext/ade_rb_opt.pyx b/autodE/source/autode/ext/ade_rb_opt.pyx new file mode 100644 index 0000000000000000000000000000000000000000..6692afa5cc0d301bc3d3b2b93a9d4797627fa012 --- /dev/null +++ b/autodE/source/autode/ext/ade_rb_opt.pyx @@ -0,0 +1,58 @@ +# distutils: language = c++ +# distutils: sources = [autode/ext/src/dihedrals.cpp, autode/ext/src/molecule.cpp, autode/ext/src/optimisers.cpp, autode/ext/src/potentials.cpp, autode/ext/src/utils.cpp, autode/ext/src/points.cpp] +import numpy as np +from libcpp.vector cimport vector +from libcpp cimport bool as bool_t +from autode.ext.wrappers cimport Molecule, RBPotential, SDOptimiser + + +def opt_rb_coords(py_coords, + py_bonded_matrix, + py_r0_matrix, + py_k_matrix, + py_c_matrix, + py_exponent): + """ + Minimise a set of coordinates using a repulsion + bonded potential + + Arguments: + py_coords (np.ndarray): Initial coordinates. shape = (n_atoms, 3) + py_bonded_matrix (np.ndarray(bool)): shape = (n_atoms, n_atoms) + py_r0_matrix (np.ndarray): shape = (n_atoms, n_atoms) + py_k_matrix (np.ndarray): shape = (n_atoms, n_atoms) + py_c_matrix (np.ndarray): shape = (n_atoms, n_atoms) + py_exponent (float): + + Returns: + (np.ndarray): Optimised coordinates + """ + cdef vector[double] coords = py_coords.flatten() + + # Initialise a molecule with the default null constructor, then create + cdef Molecule molecule + molecule = Molecule(coords) + + # and a repulsion + bonded potential + cdef int exponent = py_exponent + cdef vector[bool_t] bonds = py_bonded_matrix.flatten() + cdef vector[double] r0 = py_r0_matrix.flatten() + cdef vector[double] k = py_k_matrix.flatten() + cdef vector[double] c = py_c_matrix.flatten() + + cdef RBPotential potential + potential = RBPotential(exponent, # c/r_ij^exponent + bonds, # boolean array of where bonds are + r0, # ideal bond lengths for all pairs + k, # k(r-r_0)^2 for all pairs + c) # repulsive coefficient for all pairs + + # finally a steepest decent optimiser to use + cdef SDOptimiser optimiser + optimiser.run(potential, + molecule, + 500, # Maximum number of iterations + 1E-6, # Tolerance on ∆E_k->k+1 for a step k + 0.3) # Initial step size (Å) + + py_coords = np.asarray(molecule.coords).reshape(-1, 3) + return py_coords diff --git a/autodE/source/autode/ext/include/dihedrals.h b/autodE/source/autode/ext/include/dihedrals.h new file mode 100644 index 0000000000000000000000000000000000000000..6ee7a8bdd6714ba54be91c60421b4ff6a204072f --- /dev/null +++ b/autodE/source/autode/ext/include/dihedrals.h @@ -0,0 +1,48 @@ +#ifndef ADE_EXT_DIHEDRALS_H +#define ADE_EXT_DIHEDRALS_H +#include "vector" + +namespace autode { + + class Dihedral { + // Single dihedral angle that can be rotated given a molecule + + public: + + double angle = 0.0; // Value in radians + double grad = 0.0; // Gradient of a potential with respect + // to this dihedral + + std::vector axis_idxs; // Atom pair defining the axis + std::vector rotate_idx; // Atom indexes that should be rotated + int origin_idx = 0; // Atom index of the origin atom + + std::vector origin; // Origin vector shape = (3,) + std::vector axis; // Axis vector shape = (3,) + std::vector rot_mat; // Rotation matrix, (3,3) -> (9,) + + explicit Dihedral(); + explicit Dihedral(double angle, + std::vector axis, + std::vector rot_idxs, + int origin); + + void update_origin(const std::vector &coordinates); + + void update_axis(const std::vector &coordinates); + + void update_rotation_matrix(); + + void shift_atom_to_origin(std::vector &coordinates, + int atom_idx); + + void shift_atom_from_origin(std::vector &coordinates, + int atom_idx); + + void apply_rotation(std::vector &coordinates, + int atom_idx); + }; + +} + +#endif //ADE_EXT_DIHEDRALS_H diff --git a/autodE/source/autode/ext/include/molecule.h b/autodE/source/autode/ext/include/molecule.h new file mode 100644 index 0000000000000000000000000000000000000000..1f5ab7444610eab6a340385b0caf0d732ade6865 --- /dev/null +++ b/autodE/source/autode/ext/include/molecule.h @@ -0,0 +1,36 @@ +#ifndef ADE_EXT_MOLECULE_H +#define ADE_EXT_MOLECULE_H +#include "vector" +#include "dihedrals.h" + + +namespace autode { + + class Molecule { + public: + + int n_atoms = 0; + double energy = 0.0; + + std::vector _dihedrals; + + std::vector coords; + std::vector grad; + + // Constructors + explicit Molecule(); + explicit Molecule(std::vector coords); + + double sq_distance(int i, int j); + double distance(int i, int j); + + void rotate(autode::Dihedral &dihedral); + void rotate(std::vector &dihedrals); + + void zero_dihedrals(); + void rotate_dihedrals(); + }; + +} + +#endif //ADE_EXT_MOLECULE_H diff --git a/autodE/source/autode/ext/include/optimisers.h b/autodE/source/autode/ext/include/optimisers.h new file mode 100644 index 0000000000000000000000000000000000000000..ccbb542b89923a5a516f429f91fe0f86b8533b5b --- /dev/null +++ b/autodE/source/autode/ext/include/optimisers.h @@ -0,0 +1,94 @@ +#ifndef ADE_EXT_OPTIMISERS_H +#define ADE_EXT_OPTIMISERS_H +#include "vector" +#include "potentials.h" + + +namespace autode { + + class Optimiser{ + // Type of optimiser + + public: + // Abstract functions + virtual void run(autode::Potential &potential, + autode::Molecule &molecule, + int max_iterations, + double energy_tol, + double init_step_size) = 0; + + protected: + virtual void step(autode::Molecule &molecule, + double step_factor) = 0; + + }; + + + class SDOptimiser: public Optimiser{ + // Steepest decent optimiser + + public: + void run(autode::Potential &potential, + autode::Molecule &molecule, + int max_iterations, + double energy_tol, + double init_step_size) override; + + protected: + void step(autode::Molecule &molecule, double step_factor) override; + void trust_step(autode::Molecule &molecule, + double step_factor, + double trust_radius = 0.1); + + }; + + + class SDDihedralOptimiser: public SDOptimiser{ + // Steepest decent optimiser on a set of dihedral angles + + protected: + void check_dihedrals(autode::Molecule &molecule); + void step(autode::Molecule &molecule, double step_factor) override; + }; + + + class GridDihedralOptimiser: public SDDihedralOptimiser{ + // Steepest decent optimiser + + double spacing = 0.0; + + int num_1d_points = 0; + int num_points = 0; + int n_angles = 0; + + std::vector counter; + + public: + void run(autode::Potential &potential, + autode::Molecule &molecule, + int max_num_points, + double energy_tol, + double init_step_size) override; + + protected: + void apply_single_rotation(autode::Molecule &molecule, int curr_step); + void step(autode::Molecule &molecule, double step_factor) override; + }; + + + class SGlobalDihedralOptimiser: public SDDihedralOptimiser{ + + public: + void run(autode::Potential &potential, + autode::Molecule &molecule, + int max_init_points, + double energy_tol, + double init_step_size) override; + + protected: + void step(autode::Molecule &molecule, double step_factor) override; + }; +} + + +#endif //ADE_EXT_OPTIMISERS_H diff --git a/autodE/source/autode/ext/include/points.h b/autodE/source/autode/ext/include/points.h new file mode 100644 index 0000000000000000000000000000000000000000..5c526c29bee41f21ad71275cbc62a8008ff03638 --- /dev/null +++ b/autodE/source/autode/ext/include/points.h @@ -0,0 +1,47 @@ +#ifndef ADE_EXT_POINTS_H +#define ADE_EXT_POINTS_H +#include "vector" + +namespace autode { + + class CubePointGenerator { + + private: + // Attributes + std::vector> s_grad; + std::vector delta_point; + + // Constructor helpers + void set_init_random_points(); + void shift_box_centre(); + + // Distance functions + void set_delta_point_pbc(int i, int j); + double norm_squared_delta_point(); + + public: + int dim = 0; + int n = 0; + + double min_val = 0.0; + double max_val = 1.0; + double box_length = 1.0; + double half_box_length = 0.5; + + // Gradient functions + double norm_grad(); + + std::vector> points; + + explicit CubePointGenerator(); + explicit CubePointGenerator(int n_points, + int dimension, + double min_val = -3.145, + double max_val = 3.145); + + void set_grad(); + void run(double grad_tol, double step_size, int max_iterations); + }; +} + +#endif //ADE_EXT_POINTS_H diff --git a/autodE/source/autode/ext/include/potentials.h b/autodE/source/autode/ext/include/potentials.h new file mode 100644 index 0000000000000000000000000000000000000000..d1de91561029f426a3b4ce04c2059bd740195a74 --- /dev/null +++ b/autodE/source/autode/ext/include/potentials.h @@ -0,0 +1,107 @@ +#ifndef ADE_EXT_POTENTIALS_H +#define ADE_EXT_POTENTIALS_H +#include "vector" +#include "molecule.h" + + +namespace autode { + + class Potential{ + // Potential energy function that given a function can return the + // energy and gradient + + public: + void check_grad(autode::Molecule &mol, double tol = 1E-6); + + // Abstract functions that must be implemented in derived classes + virtual void set_energy(autode::Molecule &molecule) = 0; + virtual void set_energy_and_num_grad(autode::Molecule &molecule, double eps) = 0; + + // All potentials have a gradient method that defaults to numerical + virtual void set_energy_and_grad(autode::Molecule &molecule){ + set_energy_and_num_grad(molecule, 1E-10); + }; + + }; + + + class CartesianPotential: public Potential{ + // Potential energy the gradient of which is in cartesian coordinates + void set_energy_and_num_grad(autode::Molecule &mol, + double eps) override; + }; + + + class DihedralPotential: public Potential{ + // Potential energy the gradient of which is with respect to dihedral + // rotations + + void set_energy_and_num_grad(autode::Molecule &mol, + double eps) override; + }; + + + class RDihedralPotential: public DihedralPotential{ + // Simple purely repulsive (R) potential for dihedrals + + public: + int half_rep_exponent = 0; + std::vector rep_pairs; + + explicit RDihedralPotential(); + explicit RDihedralPotential(int rep_exponent, + std::vector rep_pairs); + + void set_energy(autode::Molecule &molecule) override; + + }; + + + class RRingDihedralPotential: public DihedralPotential{ + // Dihedral potential for a ring, with a repulsive (R) plus single + // harmonic term to the energy over the atom pair that closes thr ring + + public: + int half_rep_exponent = 0; + std::vector rep_pairs; + + int close_idx_i = 0; // Assume at least two atoms.. + int close_idx_j = 1; + double close_distance = 1.5; // Reasonable default distance (Å) + + explicit RRingDihedralPotential(); + explicit RRingDihedralPotential(int rep_exponent, + std::vector rep_pairs, + std::vector close_pair, + double close_distance); + + void set_energy(autode::Molecule &mol) override; + + }; + + + class RBPotential: public CartesianPotential{ + // Simple repulsion (R) plus bonded (B) potential + + public: + int rep_exponent = 0; + std::vector bonds; + std::vector r0; + std::vector k; + std::vector c; + + explicit RBPotential(); + explicit RBPotential(int rep_exponent, + std::vector bonds, + std::vector r0, + std::vector k, + std::vector c); + + void set_energy_and_grad(autode::Molecule &molecule) override; + void set_energy(autode::Molecule &molecule) override; + + }; + +} + +#endif //ADE_EXT_POTENTIALS_H diff --git a/autodE/source/autode/ext/include/utils.h b/autodE/source/autode/ext/include/utils.h new file mode 100644 index 0000000000000000000000000000000000000000..183ac06f88c672950314cb84abc32fcf3f8f0626 --- /dev/null +++ b/autodE/source/autode/ext/include/utils.h @@ -0,0 +1,17 @@ +#ifndef ADE_EXT_UTILS_H +#define ADE_EXT_UTILS_H + + +namespace autode{ + + namespace utils{ + + int fpowi(int value, int root); + + int powi(int value, int exponent); + } + +} + + +#endif //ADE_EXT_UTILS_H diff --git a/autodE/source/autode/ext/src/dihedrals.cpp b/autodE/source/autode/ext/src/dihedrals.cpp new file mode 100644 index 0000000000000000000000000000000000000000..5de3bcde0a2ad997ecf585668af4c0a8f025b4f8 --- /dev/null +++ b/autodE/source/autode/ext/src/dihedrals.cpp @@ -0,0 +1,135 @@ +#include "cmath" +#include "dihedrals.h" + + +namespace autode{ + + Dihedral::Dihedral() = default; + + Dihedral::Dihedral(double angle, + std::vector axis, + std::vector rot_idxs, + int origin){ + /* Single dihedral angle, e.g. + * + * CH3 + * / + * H2C ---- CH2 + * / + * H3C Carbon atoms [0, 1, 2, 3] + * + * Has: angle = π, axis = [1, 2], rot_idxs = [2, 3, ...], origin = 2 + * + * Arguments: + * + * angle: Desired change in angle for this dihedral + * + * axis: Atom pair defining the axis about which to rotate around + * + * rot_idxs: Booleans for all atoms in the structure, if true then + * will perform a rotation + * + * origin: Atom index for the origin about which to rotate + */ + + this->angle = angle; + this->axis_idxs = std::move(axis); + this->rotate_idx = std::move(rot_idxs); + this->origin_idx = origin; + + // Current origin in the molecule and axis (vector) + this->origin = std::vector(3, 0.0); + this->axis = std::vector(3, 0.0); + + // Flat 3x3 rotation matrix that will be updated + this->rot_mat = std::vector(9, 0.0); + } + + void Dihedral::update_origin(const std::vector &coordinates) { + /* Update the origin in 3D space given a set of coordinates + * and an atom index + */ + for (int k = 0; k < 3; k++){ + origin[k] = coordinates[3*origin_idx + k]; + } + } + + void Dihedral::update_axis(const std::vector &coordinates){ + /* + * + */ + for (int k = 0; k < 3; k++){ + axis[k] = (coordinates[3*axis_idxs[0] + k] + - coordinates[3*axis_idxs[1] + k]); + } + + } + + void Dihedral::update_rotation_matrix(){ + + + double norm = sqrt(axis[0]*axis[0] + axis[1]*axis[1] + axis[2]*axis[2]); + double sin_theta = sin(angle/2.0); + + double a = cos(angle/2.0); + double b = -axis[0] * sin_theta / norm; + double c = -axis[1] * sin_theta / norm; + double d = -axis[2] * sin_theta / norm; + + // Set the components of the flat rotation matrix + rot_mat[0*3 + 0] = a*a+b*b-c*c-d*d; + rot_mat[0*3 + 1] = 2.0*(b*c+a*d); + rot_mat[0*3 + 2] = 2.0*(b*d-a*c); + + rot_mat[1*3 + 0] = 2.0*(b*c-a*d); + rot_mat[1*3 + 1] = a*a+c*c-b*b-d*d; + rot_mat[1*3 + 2] = 2.0*(c*d+a*b); + + rot_mat[2*3 + 0] = 2.0*(b*d+a*c); + rot_mat[2*3 + 1] = 2.0*(c*d-a*b); + rot_mat[2*3 + 2] = a*a+d*d-b*b-c*c; + } + + void Dihedral::shift_atom_to_origin(std::vector &coordinates, + int atom_idx){ + // Shift coordinates to the current dihedral origin + + coordinates[3*atom_idx + 0] -= origin[0]; + coordinates[3*atom_idx + 1] -= origin[1]; + coordinates[3*atom_idx + 2] -= origin[2]; + } + + void Dihedral::shift_atom_from_origin(std::vector &coordinates, + int atom_idx){ + // Shift coordinates from the current dihedral origin back to the + // previous position + + coordinates[3*atom_idx + 0] += origin[0]; + coordinates[3*atom_idx + 1] += origin[1]; + coordinates[3*atom_idx + 2] += origin[2]; + } + + void Dihedral::apply_rotation(std::vector &coordinates, + int atom_idx){ + /* Apply a rotation to a single atom, moving some a coordinate + * using the current rotation matrix + */ + + double x = coordinates[3*atom_idx + 0]; + double y = coordinates[3*atom_idx + 1]; + double z = coordinates[3*atom_idx + 2]; + + coordinates[3*atom_idx + 0] = (rot_mat[0*3 + 0] * x + + rot_mat[0*3 + 1] * y + + rot_mat[0*3 + 2] * z); + + coordinates[3*atom_idx + 1] = (rot_mat[1*3 + 0] * x + + rot_mat[1*3 + 1] * y + + rot_mat[1*3 + 2] * z); + + coordinates[3*atom_idx + 2] = (rot_mat[2*3 + 0] * x + + rot_mat[2*3 + 1] * y + + rot_mat[2*3 + 2] * z); + } + +} diff --git a/autodE/source/autode/ext/src/molecule.cpp b/autodE/source/autode/ext/src/molecule.cpp new file mode 100644 index 0000000000000000000000000000000000000000..c0ababecc4ab7a3a515f0bb8e52e85c8680e6079 --- /dev/null +++ b/autodE/source/autode/ext/src/molecule.cpp @@ -0,0 +1,121 @@ +#include "stdexcept" +#include "cmath" +#include "molecule.h" +#include "dihedrals.h" + + +namespace autode { + + // Cython needs null constructors to initialise + Molecule::Molecule() = default; + + // Overloaded constructor + Molecule::Molecule(std::vector coords){ + /* autodE Molecule + * + * Arguments: + * coords: Coordinates as a flat vector 3*n_atoms with the structure + * x0, y0, z0, x1, y1, z1, ... + * + * bonds: Existence of a bond between a pair of atoms i, j as a flat + * vector. i,e, n_atoms*n_atoms long in row major indexing + */ + + if (coords.size() % 3 != 0){ + throw std::runtime_error("Coordinate must be a flat vector with " + "size 3xn_atoms"); + } + + this->coords = std::move(coords); + this->n_atoms = static_cast(this->coords.size()) / 3; + + // Initialise a zero initial energy + this->energy = 0.0; + + // Initialise a zero gradient vector for all components + this->grad = std::vector(3 * this->n_atoms, 0.0); + + } + + + double Molecule::sq_distance(int i, int j) { + /* + * Square pairwise euclidean distance between two atoms. Be careful + * - no bounds check! + * + * Arguments: + * + * i: First atom index + * + * j: Second atom index + */ + + double dx = coords[3*i + 0] - coords[3*j + 0]; + double dy = coords[3*i + 1] - coords[3*j + 1]; + double dz = coords[3*i + 2] - coords[3*j + 2]; + + return dx*dx + dy*dy + dz*dz; + } + + + double Molecule::distance(int i, int j) { + /* + * Pairwise euclidean distance between two atoms. Be careful - no + * bounds check! + * + * Arguments: + * + * i: First atom index + * + * j: Second atom index + */ + + return sqrt(sq_distance(i, j)); + } + + + void Molecule::rotate(autode::Dihedral &dihedral){ + // Rotate a single dihedral angle + + dihedral.update_origin(coords); + dihedral.update_axis(coords); + dihedral.update_rotation_matrix(); + + // Apply the rotation to only the required atoms + for (int atom_idx=0; atom_idx < n_atoms; atom_idx++){ + + if (!dihedral.rotate_idx[atom_idx]){ + continue; + } + + dihedral.shift_atom_to_origin(coords, atom_idx); + dihedral.apply_rotation(coords, atom_idx); + dihedral.shift_atom_from_origin(coords, atom_idx); + + } // atoms + } + + + void Molecule::rotate(std::vector &dihedrals) { + // Perform a rotation to the desired angle on each dihedral + + for (auto &dihedral: dihedrals){ + rotate(dihedral); + } + } + + + void Molecule::zero_dihedrals() { + // Set all the dihedral angle changes to be applied to zero + + for (auto &dihedral: _dihedrals){ + dihedral.angle = 0.0; + } + } + + + void Molecule::rotate_dihedrals(){ + rotate(_dihedrals); + } + +} diff --git a/autodE/source/autode/ext/src/optimisers.cpp b/autodE/source/autode/ext/src/optimisers.cpp new file mode 100644 index 0000000000000000000000000000000000000000..0787b85c123110054421ea1d6d0f9c7660811117 --- /dev/null +++ b/autodE/source/autode/ext/src/optimisers.cpp @@ -0,0 +1,334 @@ +#include "optimisers.h" +#include "utils.h" +#include "points.h" +#include +#include + + +namespace autode { + + void SDOptimiser::step(autode::Molecule &molecule, double step_factor){ + // Perform a SD step in the direction of the gradient + + for (int i = 0; i < 3 * molecule.n_atoms; i++) { + molecule.coords[i] -= step_factor * molecule.grad[i]; + } + } + + + void SDOptimiser::trust_step(autode::Molecule &molecule, + double step_factor, + double trust_radius){ + // Perform a SD step in the direction of the gradient with a maximum + // absolute displacement of the trust radius + double max_abs_delta = 0.0; + + for (int i = 0; i < 3 * molecule.n_atoms; i++) { + max_abs_delta = std::max(max_abs_delta, + std::abs(step_factor * molecule.grad[i])); + } + // std::cout << max_abs_delta << "\n\n\n\n" << std::endl; + double trust_factor = std::min(trust_radius / max_abs_delta, 1.0); + + for (int i = 0; i < 3 * molecule.n_atoms; i++) { + molecule.coords[i] -= step_factor * trust_factor * molecule.grad[i]; + } + } + + + void SDOptimiser::run(autode::Potential &potential, + autode::Molecule &molecule, + int max_iterations, + double energy_tol, + double init_step_size) { + /* + * Steepest decent optimiser + * + * Arguments: + * potential: + * + * molecule: + * + * max_iterations: Maximum number of macro iterations + * (total = max_iterations * max_micro_iterations) + * + * energy_tol: ΔE between iterations to signal convergence + * + * init_step_size: (Å) + */ + double max_micro_iterations = 20; + double curr_energy = 99999999.9; + double curr_micro_energy; + + int iteration = 0; + + while (std::abs(molecule.energy - curr_energy) > energy_tol + and iteration <= max_iterations){ + + curr_energy = molecule.energy; + int micro_iteration = 0; + double step_size = init_step_size; + + // Recompute the energy and gradient + potential.set_energy_and_grad(molecule); + + // Run a set of micro iterations as a line search in the steepest + // decent direction: -∇V + while (micro_iteration < max_micro_iterations){ + + curr_micro_energy = molecule.energy; + step(molecule, step_size); + + potential.set_energy(molecule); + + // Prevent very small step sizes + if (step_size < 1E-3){ + break; + } + + // Backtrack if the energy rises + if (micro_iteration == 0 + and molecule.energy > curr_micro_energy){ + + step(molecule, -step_size); + molecule.energy = curr_micro_energy; + step_size *= 0.5; + + continue; + } + + if (molecule.energy > curr_micro_energy){ + // Energy has risen but at least one step has been taken + // so return the previous step + step(molecule, -step_size); + break; + } + + micro_iteration += 1; + } // Micro + + iteration += 1; + } // Macro + } + + + void SDDihedralOptimiser::check_dihedrals(autode::Molecule &molecule) { + /* Ensure that there is at least one dihedral to minimise on + * + * Arguments: + * + * molecule: + */ + if (molecule._dihedrals.empty()){ + throw std::runtime_error("Cannot run a dihedral minimisation " + "without any dihedrals!"); + } + } + + + void SDDihedralOptimiser::step(autode::Molecule &molecule, + double step_factor) { + /* + * Apply a set of dihedral rotations given a new gradient + * + * Arguments: + * + * molecule: + * + * step_factor: Multiplier on the SD step + */ + + for (auto &dihedral: molecule._dihedrals){ + dihedral.angle = -step_factor * dihedral.grad; + molecule.rotate(dihedral); + } + } + + + void GridDihedralOptimiser::run(autode::Potential &potential, + autode::Molecule &molecule, + int max_num_points, + double energy_tol, + double init_step_size) { + /* Complete optimisation of the dihedral surface using a grid with + * a spacing defined by max_num_points^(1/n_dihedrals), plus a final + * steepest decent (SD) minimisation. Can be 1D, 2D... nD grid + * + * Arguments: + * potential: + * + * molecule: + * + * max_num_points: Maximum number of points in the grid, optimiser + * may exceed this number of evaluations + * + * energy_tol: Tolerance on the final SD minimisation + * + * init_step_size: For SD + */ + SDDihedralOptimiser::check_dihedrals(molecule); + + n_angles = static_cast(molecule._dihedrals.size()); + + if (n_angles == 0){ + // Nothing to be done with no dihedrals to rotate + return; + } + + if (n_angles > 5){ + throw std::runtime_error("Number of points required to use a " + "reasonably spaced grid is huge. Not " + "supported"); + } + + num_1d_points = autode::utils::fpowi(max_num_points, n_angles); + num_points = autode::utils::powi(num_1d_points, n_angles); + + molecule.zero_dihedrals(); + + // Spacing is taken over 0 -> 2π - π/3, as the dihedral is periodic + spacing = 5.2 / static_cast(num_1d_points); + + /* Generate a counter wheel for each dihedral, which when it reaches + * num_1d_points then the adjacent wheel is incremented etc. + * + * | | + * | 0 0 0 ... | + * | | + * + * Initialised at 0 on every angle + */ + counter = std::vector(n_angles, 0); + + std::vector min_coords; + double min_energy = 99999999.9; + + for (int i=0; i < num_points; i++){ + + apply_single_rotation(molecule, i); + potential.set_energy(molecule); + + if (molecule.energy < min_energy) { + min_coords = std::vector(molecule.coords); + min_energy = molecule.energy; + } + } + + molecule.coords = min_coords; + + // Perform a steepest decent optimisation from this point + SDDihedralOptimiser::run(potential, + molecule, + 100, + energy_tol, + init_step_size); + + } + + + void GridDihedralOptimiser::step(autode::Molecule &molecule, + double step_factor) { + // Take a step on the grid + SDDihedralOptimiser::step(molecule, step_factor); + } + + + void GridDihedralOptimiser::apply_single_rotation(autode::Molecule &molecule, + int curr_step) { + /* Apply a single rotation to one dihedral in sequence, depending on + * the value of the counter + * + * Arguments: + * + * molecule: + * + * + * curr_step: Current step on the N-dimensional grid + */ + for (int j=0; j < n_angles; j++){ + + int value = ((curr_step + / autode::utils::powi(num_1d_points, j) + ) % num_1d_points); + + if (value == num_1d_points - 1){ + counter[j] = 0; + continue; + } + + if (value != counter[j]){ + molecule._dihedrals[j].angle = spacing; + molecule.rotate(molecule._dihedrals[j]); + + counter[j] = value; + break; + } + } + } + + + void SGlobalDihedralOptimiser::run(autode::Potential &potential, + autode::Molecule &molecule, + int max_init_points, + double energy_tol, + double init_step_size) { + /* Stochastic global minimisation + * + * Arguments: + * + * potential: + * + * molecule: + * + * max_init_points: Number of initial points to start SD + * optimisations from, each of which is subject to + * a fast (few step) optimisation + * + * energy_tol: Energy tolerance on the final SD minimisation + * + * + * init_step_size: Initial step size for all (search + final) + * optimisations + */ + SDDihedralOptimiser::check_dihedrals(molecule); + + std::vector min_coords; + double min_energy = 99999999.9; + + // Generate a set of points in a n-dimensional space, where n is the + // number of dihedrals in the system + CubePointGenerator generator = CubePointGenerator(max_init_points, molecule._dihedrals.size()); + generator.run(1E-4, 0.01, 200); + + for (int iteration=0; iteration < max_init_points; iteration++){ + + for (size_t i=0; i < molecule._dihedrals.size(); i++){ + molecule._dihedrals[i].angle = generator.points[iteration][i]; + molecule.rotate(molecule._dihedrals[i]); + } + + // Apply a steepest decent minimisation for a few steps + SDDihedralOptimiser::run(potential, + molecule, + 10, + 1E-1, + init_step_size); + + if (molecule.energy < min_energy) { + min_coords = std::vector(molecule.coords); + min_energy = molecule.energy; + } + } + + // Set the minimum energy coordinates + molecule.coords = min_coords; + } + + + void SGlobalDihedralOptimiser::step(autode::Molecule &molecule, + double step_factor) { + // Apply a steepest decent step + SDDihedralOptimiser::step(molecule, step_factor); + } + +} \ No newline at end of file diff --git a/autodE/source/autode/ext/src/points.cpp b/autodE/source/autode/ext/src/points.cpp new file mode 100644 index 0000000000000000000000000000000000000000..1fd791d3e4a401d2fa2927602017091cff1b4637 --- /dev/null +++ b/autodE/source/autode/ext/src/points.cpp @@ -0,0 +1,253 @@ +#include +#include +#include +#include "points.h" + + +namespace autode { + + CubePointGenerator::CubePointGenerator() = default; + + CubePointGenerator::CubePointGenerator(int n_points, + int dimension, + double min_val, + double max_val) { + /* Generate a set of n points evenly spaced in a (hyper)cube with + * dimension d, with side length + * + * l = max_val - min_val + * + * Arguments: + * n: Number of points to generate + * + * dim: Dimension of the space to generate the points in + * + * min_val: Minimum value in the box (single dimension) + * + * max_val: + */ + if (n_points < 2){ + throw std::runtime_error("Must have at least 2 points to generate " + "a point set"); + } + + this->n = n_points; + this->dim = dimension; + + this->min_val = min_val; + this->max_val = max_val; + + this->box_length = (max_val - min_val); + this->half_box_length = (max_val - min_val) / 2.0; + + if (box_length < 0){ + throw std::runtime_error("Must have a positive side length. i.e. " + "min_val < max_val"); + } + + // Gradient with respect to point displacement + this->s_grad = std::vector>(n_points, + std::vector(dimension, 0.0)); + + // ∆X_ij = {(x_i - x_j), (y_i - y_j), ...} + this->delta_point = std::vector(dimension, 0.0); + + set_init_random_points(); + } + + + void CubePointGenerator::set_init_random_points(){ + /* Set the set of points using random uniform distribution within a + * box, centred at the origin (for more simple periodic + * boundary conditions) + */ + points.clear(); + s_grad.clear(); + + std::random_device rand_device; + + std::uniform_real_distribution unif_distro(-half_box_length, + half_box_length); + std::default_random_engine rand_generator(rand_device()); + + // Initialise all the points randomly in the space + for (int i=0; i < n; i++){ + std::vector point; + point.reserve(dim); + + // with dim members per point + for (int j=0; j < dim; j++){ + point.push_back(unif_distro(rand_generator)); + } + + points.push_back(point); + + // Initialise a zero gradient initially + s_grad.emplace_back(point.size(), 0.0); + } + set_grad(); + + // Prevent very close initial geometries -> large gradients + if (norm_grad() > 100){ + set_init_random_points(); + } + } + + + double CubePointGenerator::norm_grad() { + /* Calculate the norm of the gradient vector + */ + double norm = 0.0; + + for (auto &grad : s_grad){ + for (auto &component : grad){ + norm += component * component; + } + } + + return sqrt(norm); + } + + + void CubePointGenerator::set_delta_point_pbc(int i, int j){ + /* Calculate the components of the ∆X_ij vector, in 2D + * + * ∆X_ij = {(x_i - x_j), (y_i - y_j)} + * + * with periodic boundary conditions, such that in each direction + * the nearest atom is, at most, half a box length away. + * + * Arguments: + * i: Index of one point + * + * j: Index of another point + */ + + for (int k = 0; k < dim; k++){ + delta_point[k] = points[i][k] - points[j][k]; + + // Apply the nearest image convention in all directions + if (delta_point[k] > half_box_length){ + delta_point[k] -= box_length; + } + else if (delta_point[k] < -half_box_length){ + delta_point[k] += box_length; + } + } // k + } + + + void CubePointGenerator::shift_box_centre(){ + /* Shift the box back such that the center is between min_val, max_val + * in all dimensions + */ + for (auto &point : points) { + for (auto &component: point) { + component += (max_val - min_val) / 2.0; + } // k + } + } + + + double CubePointGenerator::norm_squared_delta_point(){ + /* + * + */ + double norm_squared = 0.0; + + for (auto &component : delta_point){ + norm_squared += component * component; + } + + return norm_squared; + } + + + void CubePointGenerator::set_grad(){ + /* Calculate the gradient with respect to the points + * + * E = Σ'_ij 1 / |x_i - x_j| + * + * where x_i is a vector in dim-dimensional space with a distance + * + * |x_i - x_j| = √[(x_i0 x_j0)^2 + (x_i1 x_j1)^2 + ... ] + * + */ + + for (int i=0; i < n; i++) { + // Zero the gradient of all (x, y, z, ..) components + std::fill(s_grad[i].begin(), s_grad[i].end(), 0.0); + + // Should loop for all i, j and j, i but not i = j + for (int j = 0; j < n; j++) { + + if (i == j) continue; + + set_delta_point_pbc(i, j); + auto rep_ftr = -1.0 / norm_squared_delta_point(); + + for (int k = 0; k < dim; k++){ + s_grad[i][k] += rep_ftr * delta_point[k]; + } + + } // j + } // i + } + + + void CubePointGenerator::run(double grad_diff_tol = 1E-4, + double step_size = 0.1, + int max_iterations = 100) { + /* Generate a set of n points evenly spaced in a dimension d. Sets + * CubePointGenerator.points. Will minimise the Coulomb energy between + * the points (as J. J. Thomson in 1904 in 3D) from a random starting + * point + * + * Arguments: + * grad_diff_tol: Tolerance on the absolute difference between + * sequential gradient evaluations |∇V_i - ∇V_i+1| + * + * step size: Fixed step size to take in the steepest decent + * + * max_iterations: + */ + set_grad(); + int iteration = 0; + + double _norm_grad = 0.0; + double _norm_grad_prev = 2*grad_diff_tol; + + while (fabs(_norm_grad - _norm_grad_prev) > grad_diff_tol && iteration < max_iterations) { + + _norm_grad_prev = _norm_grad; + + for (int point_idx = 0; point_idx < n; point_idx++) { + // Do a steepest decent step + + for (int k = 0; k < dim; k++) { + + // Ensure the translation is not more than the whole + // box length + points[point_idx][k] -= step_size * s_grad[point_idx][k]; + + if (points[point_idx][k] > half_box_length) { + points[point_idx][k] -= box_length; + } else if (points[point_idx][k] < -half_box_length) { + points[point_idx][k] += box_length; + } + + + } // k + }// point_idx + + set_grad(); + _norm_grad = norm_grad(); + + iteration++; + } + + shift_box_centre(); + } + + +} diff --git a/autodE/source/autode/ext/src/potentials.cpp b/autodE/source/autode/ext/src/potentials.cpp new file mode 100644 index 0000000000000000000000000000000000000000..178f6c5dacfbf9f75dd63566eba34e3d1f42fb31 --- /dev/null +++ b/autodE/source/autode/ext/src/potentials.cpp @@ -0,0 +1,339 @@ +#include +#include +#include +#include +#include +#include "potentials.h" + + +namespace autode{ + + void CartesianPotential::set_energy_and_num_grad(autode::Molecule &mol, + double eps) { + /* + * Calculate a finite difference gradient + * + * Arguments: + * mol: + * + * eps: δ on each x + */ + set_energy(mol); + auto energy = mol.energy; + + for (int i = 0; i < mol.n_atoms; i++){ // atoms + for (int j = 0; j < 3; j++){ // x, y, z + int idx = 3 * i + j; + + // Shift by δ in each coordinate, calculate the energy + mol.coords[idx] += eps; + set_energy(mol); + + // and the finite difference gradient + mol.grad[idx] = (mol.energy - energy) / eps; + + // and shift the modified coordinates back + mol.coords[idx] -= eps; + + } // j + } // i + } + + + void Potential::check_grad(autode::Molecule &mol, + double tol) { + /* + * Check the analytic gradient against the numerical analogue + * + * Arguments: + * mol: + * + * Raises: + * runtime_error: If the norm is greater than tol + */ + set_energy_and_grad(mol); + + // copy of the analytic gradient + std::vector analytic_grad(mol.grad); + + set_energy_and_num_grad(mol, 1E-10); + + double sq_norm = 0; + for (int i = 0; i < 3 * mol.n_atoms; i++){ + sq_norm += pow(mol.grad[i] - analytic_grad[i], 2); + } + + if (sqrt(sq_norm) > tol){ + throw std::runtime_error("Difference between the analytic and " + "numerical gradients exceeded the tolerance"); + } + } + + + RDihedralPotential::RDihedralPotential() = default; + RDihedralPotential::RDihedralPotential(int rep_exponent, + std::vector rep_pairs) { + /* Purely repulsive energy + * + * V(X) = Σ_ij 1 / r_ij^rep_exponent + * + * Arguments: + * rep_exponent: Integer exponent + * + * rep_pairs: Boolean row major vector (flat matrix) of atom pairs + * that should be considered for a pairwise repulsion + * should be a flat symmetric matrix, but only the + * upper triangle is used (column index > row index) + */ + + if (rep_exponent % 2 != 0){ + throw std::runtime_error("Repulsion exponent must be even"); + } + + this->half_rep_exponent = rep_exponent / 2; + this->rep_pairs = std::move(rep_pairs); + } + + void RDihedralPotential::set_energy(autode::Molecule &mol) { + // Repulsion energy + + mol.energy = 0.0; + + for (int i = 0; i < mol.n_atoms; i++){ + for (int j = i+1; j < mol.n_atoms; j++) { + + if (rep_pairs[i * mol.n_atoms + j]){ + + if (half_rep_exponent == 1){ + mol.energy += 1.0 / mol.sq_distance(i, j); + } + else { + mol.energy += 1.0 / pow(mol.sq_distance(i, j), + half_rep_exponent); + } + } + + } // j + } // i + } + + + void DihedralPotential::set_energy_and_num_grad(autode::Molecule &mol, + double eps) { + set_energy(mol); + auto energy = mol.energy; + + for (auto &dihedral : mol._dihedrals){ + + // Shift by δ on a dihedral + dihedral.angle = eps; + mol.rotate(dihedral); + set_energy(mol); + + // calculate the finite difference gradient + dihedral.grad = (mol.energy - energy) / eps; + + // and shift the modified coordinates back + dihedral.angle = -eps; + mol.rotate(dihedral); + + } // i + } + + + RRingDihedralPotential::RRingDihedralPotential() = default; + RRingDihedralPotential::RRingDihedralPotential(int rep_exponent, + std::vector rep_pairs, + std::vector close_pair, + double close_distance) { + /* Repulsive dihedral potential in a ring e.g.:: + * + * X Z + * | / + * Y---- W + * + * for a 4 membered ring with a single dihedral (X, Y, W, Z) + * + * V(x) = 1/n Σ_ij 1 / r_ij^rep_exponent + (r_XZ - r0)^2 + * + * Arguments: + * + * rep_exponent: + */ + + if (rep_exponent % 2 != 0){ + throw std::runtime_error("Repulsion exponent must be even"); + } + + this->half_rep_exponent = rep_exponent / 2; + this->rep_pairs = std::move(rep_pairs); + + if (close_pair.size() != 2){ + throw std::runtime_error("Must have a pair of atoms that close a " + "ring"); + } + + if ((close_distance < 0.5) || (close_distance > 3.0)){ + throw std::runtime_error("Had an unexpected distance between two " + "atoms that close a ring"); + } + + // Sort the pair so i <= j + std::sort(close_pair.begin(), close_pair.end()); + + this->close_idx_i = close_pair[0]; + this->close_idx_j = close_pair[1]; + this->close_distance = close_distance; + } + + void RRingDihedralPotential::set_energy(autode::Molecule &mol) { + // Energy for repulsion + closure + + mol.energy = 0.0; + double c = 1.0 / mol.n_atoms; + + for (int i = 0; i < mol.n_atoms; i++){ + for (int j = i+1; j < mol.n_atoms; j++) { + + // 1 / r_ij^rep_exponent + if (rep_pairs[i * mol.n_atoms + j]){ + mol.energy += c / pow(mol.sq_distance(i, j), half_rep_exponent); + } + + // (r_ij - r0)^2 + if (i == close_idx_i && j == close_idx_j){ + mol.energy += pow(mol.distance(i, j) - close_distance, 2); + } + + } // j + } // i + } + + RBPotential::RBPotential() = default; + + RBPotential::RBPotential(int rep_exponent, + std::vector bonds, + std::vector r0, + std::vector k, + std::vector c) { + /* + * Simple repulsion + bonded (RB) potential of the form: + * + * V(X) = Σ_ij k(r - r0)^2 + Σ_mn c/r^rep_exponent + * + * for a pair (i, j) in bonds and (m, n) in non-bonded repulsion + * term + * + * Arguments: + * rep_exponent: Power of the repulsive r^-n term + * + * Bonds: Row major vector of the existence of a 'bond' for each + * pairwise interation + * + * r0: Row major vector of equilibrium distances. Only used for + * bonded pairs + * + * k: Row major vector of force constants for the harmonic terms. + * Only used for bonded pairs + * + * c: Row major vector of the repulsion coefficient. Applies to + * all unique pairs + */ + + this->rep_exponent = rep_exponent ; + this->bonds = std::move(bonds); + this->r0 = std::move(r0); + this->k = std::move(k); + this->c = std::move(c); + + } // RBPotential + + void RBPotential::set_energy(autode::Molecule &mol) { + // Repulsion + bonded energy + + double energy = 0.0; + + for (int i = 0; i < mol.n_atoms; i++){ + for (int j = i+1; j < mol.n_atoms; j++) { + + int pair_idx = i * mol.n_atoms + j; // Compound index + + // Square euclidean distance + double r = mol.distance(i, j); + energy += c[pair_idx] / pow(r, rep_exponent);; + + // Check the bond matrix for if these atoms are bonded, don't + // add a harmonic term if not + if (! bonds[pair_idx]){ + continue; + } + energy += k[pair_idx] * pow(r - r0[pair_idx], 2); + + } // j + } // i + + mol.energy = energy; + } + + void RBPotential::set_energy_and_grad(autode::Molecule &mol) { + // Repulsion + bonded energy + + double energy = 0.0; + + for (int i = 0; i < mol.n_atoms; i++){ + + // Zero the gradient of all (x, y, z) components of this atom + mol.grad[3*i + 0] = 0.0; + mol.grad[3*i + 1] = 0.0; + mol.grad[3*i + 2] = 0.0; + + for (int j = 0; j < mol.n_atoms; j++) { + + // Only loop over non identical pairs + if (i == j) { + continue; + } + + int pair_idx = i * mol.n_atoms + j; // Compound index + + double dx = mol.coords[3*i + 0] - mol.coords[3*j + 0]; + double dy = mol.coords[3*i + 1] - mol.coords[3*j + 1]; + double dz = mol.coords[3*i + 2] - mol.coords[3*j + 2]; + + double r = sqrt(dx*dx + dy*dy + dz*dz); + double e_rep = c[pair_idx] / pow(r, rep_exponent); + + // Add half the energy term to prevent double counting + energy += 0.5 * e_rep; + + // Set the component of the derivative + auto rep_ftr = - (e_rep * static_cast(rep_exponent) + / pow(r, 2)); + + mol.grad[3*i + 0] += rep_ftr * dx; + mol.grad[3*i + 1] += rep_ftr * dy; + mol.grad[3*i + 2] += rep_ftr * dz; + + // Check the bond matrix for if these atoms are bonded, don't + // add a harmonic term if not + if (! bonds[pair_idx]){ + continue; + } + + // Calculate the harmonic energy + energy += 0.5 * k[pair_idx] * pow(r - r0[pair_idx], 2); + + // and set the gradient contribution from the harmonic bonds + auto bonded_ftr = 2.0 * k[pair_idx] * (1.0 - r0[pair_idx] / r); + mol.grad[3*i + 0] += bonded_ftr * dx; + mol.grad[3*i + 1] += bonded_ftr * dy; + mol.grad[3*i + 2] += bonded_ftr * dz; + + } // j + } // i + + mol.energy = energy; + } + +} + diff --git a/autodE/source/autode/ext/src/utils.cpp b/autodE/source/autode/ext/src/utils.cpp new file mode 100644 index 0000000000000000000000000000000000000000..16268d9da69da7e918147607cf97bdaaa386649c --- /dev/null +++ b/autodE/source/autode/ext/src/utils.cpp @@ -0,0 +1,55 @@ +#include "utils.h" +#include + + +namespace autode{ + + namespace utils{ + + int fpowi(int value, int root){ + /* Floored n-th root of an integer. e.g. + * + * fpowi(10, 2) ---> 3 + * fpowi(3, 1) ---> 3 + * + * Arguments: + * + * value: + * + * root: + * + * Returns: + * |_value^(1/root)_| + */ + + auto root_value = std::pow(static_cast(value), + 1.0 / static_cast(root)); + + return static_cast(std::floor(root_value)); + } + + int powi(int value, int exponent){ + /* Integer n-th power of an integer. e.g. + * + * powi(10, 2) ---> 100 + * powi(3, 1) ---> 3 + * + * Arguments: + * + * value: + * + * root: + * + * Returns: + * value^exponent + */ + + auto result = std::pow(static_cast(value), + static_cast(exponent)); + + return static_cast(result); + } + + } +} + diff --git a/autodE/source/autode/ext/tests/test_global_dihedral_min.cpp b/autodE/source/autode/ext/tests/test_global_dihedral_min.cpp new file mode 100644 index 0000000000000000000000000000000000000000..185d1544b6b285ef323b9c4adbae9a8345f782c3 --- /dev/null +++ b/autodE/source/autode/ext/tests/test_global_dihedral_min.cpp @@ -0,0 +1,63 @@ +#include "dihedrals.h" +#include "optimisers.h" +#include "molecule.h" +#include +#include +#include +#include + +using namespace autode; +using namespace std; + +TEST_CASE("Test a simple dihedral optimisation of eclipsed butane"){ + + vector coords = {-0.229521, 1.579629, -0.793611, // x, y, z etc. + 0.526700, 0.459300, -0.125900, + -0.533500, -0.461800, 0.494500, + -1.873700, 0.093300, 0.023300, + -0.309835, 1.403837, -1.900453, + 0.203041, 2.565908, -0.562444, + -1.255677, 1.581283, -0.353641, + 1.122306, 0.920508, 0.696217, + 1.193881, -0.091622, -0.780221, + -0.439200, -0.372600, 1.585900, + -0.376500, -1.502200, 0.196300, + -1.770000, 0.535200, -0.997800, + -2.625900, -0.707300, -0.064600, + -2.173700, 0.918700, 0.694900}; + + Molecule mol = Molecule(coords); + REQUIRE(mol.n_atoms == 14); + + vector rotate_idxs(mol.n_atoms, false); + vector idxs_to_rotate = {0, 4, 5, 6, 1, 7, 8}; + for (size_t i: idxs_to_rotate){ + rotate_idxs[i] = true; + } + + vector axis = {1, 2}; + + mol._dihedrals.emplace_back(0.0, // angle + axis, + rotate_idxs, + 1); // origin + + // Pairs to consider repelling + vector pairs(mol.n_atoms*mol.n_atoms, false); + pairs[0 + 3] = true; + pairs[3*mol.n_atoms + 0] = true; + + RDihedralPotential potential = RDihedralPotential(4, + pairs); + SDDihedralOptimiser optimiser = SDDihedralOptimiser(); + optimiser.run(potential, + mol, + 100, // Maximum iterations + 1E-5, // Energy tolerance + 0.1); // initial step size + + + // Distance between the end two carbons in the molecule should be > 3Å + // for the staggered conformation of butane + REQUIRE(mol.distance(0, 3) > 3.0); +} diff --git a/autodE/source/autode/ext/tests/test_main.cpp b/autodE/source/autode/ext/tests/test_main.cpp new file mode 100644 index 0000000000000000000000000000000000000000..4ed06df1f7bea8cc18ee161389b9c3e2741b08a0 --- /dev/null +++ b/autodE/source/autode/ext/tests/test_main.cpp @@ -0,0 +1,2 @@ +#define CATCH_CONFIG_MAIN +#include diff --git a/autodE/source/autode/ext/tests/test_point_gen.cpp b/autodE/source/autode/ext/tests/test_point_gen.cpp new file mode 100644 index 0000000000000000000000000000000000000000..f7286f698438f130220b588c684f7d4dbceb5539 --- /dev/null +++ b/autodE/source/autode/ext/tests/test_point_gen.cpp @@ -0,0 +1,94 @@ +#include "points.h" +#include +#include +#include + +using namespace std; +using namespace autode; + + +TEST_CASE("Test point generation with only a single point"){ + + REQUIRE_THROWS( + CubePointGenerator(1, // Single point + 1, + 0.0, + 1.0) + ); +} + + +TEST_CASE("Test periodic point generation in 1D"){ + CubePointGenerator pointGenerator(2, // 2 points + 1, // 1D + 0.0, // minimum value + 1.0); // maximum value + + + + pointGenerator.run(1E-3, // Gradient tolerance + 0.01, // Step size + 200); // Maximum number of iterations + + auto points = pointGenerator.points; + + REQUIRE(points.size() == 2); + REQUIRE(points[0].size() == 1); + + // ∆r between the two points should be 0.5 in a periodic 1D system with length of 1 + REQUIRE(fabs(points[0][0] - points[1][0]) == Approx(0.5).epsilon(0.05)); +} + + +double distance(vector point1, vector point2){ + /* Calculate the Euclidean distance between two points + * NOTE: Does not consider periodic boundary conditions + */ + + double dist_sq = 0.0; + int dim = point1.size(); + + for (int i = 0; i < dim; i++){ + double tmp = point1[i] - point2[i]; + dist_sq += tmp * tmp; + } // i + + return sqrt(dist_sq); +} + + +TEST_CASE("Test periodic point generation in 2D square"){ + CubePointGenerator pointGenerator(2, // 2 points + 2, // 2D (cube) + 0.0, // unit length + 1.0); + + + + pointGenerator.run(1E-4, 0.01, 100); + auto points = pointGenerator.points; + + // Minimum ∆r between the two points should be 0.707 + REQUIRE(distance(points[0], points[1]) > 0.5); +} + + +TEST_CASE("Test periodic point generation in 3D cube"){ + CubePointGenerator pointGenerator(4, // 4 points + 3, // 3D (cube) + 0.0, // unit length + 1.0); + + + + pointGenerator.run(1E-4, 0.01, 100); + auto points = pointGenerator.points; + + // Minimum ∆r between two points needs to be at least 0.4, ideally + // it would be ~0.7 (perhaps) + for (int i=0; i < 4; i++){ + for (int j=0; j < i; j++){ + REQUIRE(distance(points[i], points[j]) > 0.4); + }// j + }// i +} diff --git a/autodE/source/autode/ext/wrappers.pxd b/autodE/source/autode/ext/wrappers.pxd new file mode 100644 index 0000000000000000000000000000000000000000..2ce370f7430fbdf30b21189ce2a3adc750a5b59c --- /dev/null +++ b/autodE/source/autode/ext/wrappers.pxd @@ -0,0 +1,96 @@ +# distutils: language = c++ +from libcpp.vector cimport vector +from libcpp cimport bool as bool_t + + +cdef extern from "include/molecule.h" namespace "autode": + cdef cppclass Molecule: + # Constructors + Molecule() + Molecule(vector[double]) + + # Attributes exposed to Python + int n_atoms + double energy + vector[double] coords + vector[Dihedral] _dihedrals + + void rotate_dihedrals() + + +cdef extern from "include/potentials.h" namespace "autode": + cdef cppclass Potential: + pass + + +cdef extern from "include/potentials.h" namespace "autode": + cdef cppclass RBPotential(Potential): + RBPotential() + RBPotential(int, # Exponent + vector[bool_t], # Bonds + vector[double], # Ideal pairwise distances (r0) + vector[double], # Force constants (k) + vector[double]) # Pairwise repulsion coefficients + + +cdef extern from "include/potentials.h" namespace "autode": + cdef cppclass RDihedralPotential(Potential): + RDihedralPotential() + RDihedralPotential(int, # Exponent + vector[bool_t]) # Pairs that should be considered + + +cdef extern from "include/potentials.h" namespace "autode": + cdef cppclass RRingDihedralPotential(Potential): + RRingDihedralPotential() + RRingDihedralPotential(int, # Exponent + vector[bool_t], # Pairs that should be considered + vector[int], # Pair of atom indexes to close + double) # Distance for ring closure + + +cdef extern from "include/optimisers.h" namespace "autode": + cdef cppclass SDOptimiser: + SDOptimiser() + + # Method + void run(Potential &, + Molecule &, + int, # Max number of iterations + double, # Energy difference + double) # Initial step size + + +cdef extern from "include/optimisers.h" namespace "autode": + cdef cppclass SDDihedralOptimiser(SDOptimiser): + SDDihedralOptimiser() + + +cdef extern from "include/optimisers.h" namespace "autode": + cdef cppclass GridDihedralOptimiser: + GridDihedralOptimiser() + + void run(Potential &, + Molecule &, + int, # Maximum number of grid points + double, # Energy difference for final SD + double) # Initial step size for SD minimisation + + +cdef extern from "include/optimisers.h" namespace "autode": + cdef cppclass SGlobalDihedralOptimiser: + SGlobalDihedralOptimiser() + + void run(Potential &, + Molecule &, + int, # Number of maximum total steps + double, # Final tolerance on SD minimisation + double) # Initial step size for SD minimisation + +cdef extern from "include/dihedrals.h" namespace "autode": + cdef cppclass Dihedral: + Dihedral() + Dihedral(double, # Angle + vector[int], # Axis + vector[bool_t], # Rotation indexes + int) # Origin diff --git a/autodE/source/autode/geom.py b/autodE/source/autode/geom.py new file mode 100644 index 0000000000000000000000000000000000000000..f32a3b9bb362f5d9a26e7bec2b0bb1afde335d25 --- /dev/null +++ b/autodE/source/autode/geom.py @@ -0,0 +1,314 @@ +import numpy as np + +from typing import Sequence, Union, TYPE_CHECKING, List +from scipy.spatial.distance import cdist +from scipy.spatial import distance_matrix +from autode.log import logger + +if TYPE_CHECKING: + from autode.values import Angle + from autode.species.species import Species + from autode.atoms import Atoms + + +def are_coords_reasonable(coords: np.ndarray) -> bool: + """ + Determine if a set of coords are reasonable. No distances can be < 0.7 Å + and if there are more than 4 atoms ensure they do not all lie in the same + plane. The latter possibility arises from RDKit's conformer generation + algorithm breaking + + --------------------------------------------------------------------------- + Arguments: + coords (np.ndarray): Species coordinates as a n_atoms x 3 array + + Returns: + bool: + """ + n_atoms = len(coords) + + # Generate a n_atoms x n_atoms matrix with ones on the diagonal + dist_mat = distance_matrix(coords, coords) + np.identity(n_atoms) + + if np.min(dist_mat) < 0.7: + logger.warning( + "There is a distance < 0.7 Å. Structure is *not* " "sensible" + ) + return False + + return True + + +def proj(u: np.ndarray, v: np.ndarray) -> np.ndarray: + """ + Calculate the projection of v onto the direction of u. Useful for + https://en.wikipedia.org/wiki/Gram%E2%80%93Schmidt_process + + --------------------------------------------------------------------------- + Arguments: + u (np.ndarray): + + v (np.ndarray): + + Returns: + (np.ndarray): proj_u(v) + """ + return (np.dot(u, v) / np.dot(u, u)) * u + + +def get_rot_mat_kabsch( + p_matrix: np.ndarray, q_matrix: np.ndarray +) -> np.ndarray: + """ + Get the optimal rotation matrix with the Kabsch algorithm. Notation is from + https://en.wikipedia.org/wiki/Kabsch_algorithm + + --------------------------------------------------------------------------- + Arguments: + p_matrix (np.ndarray): + + q_matrix (np.ndarray): + + Returns: + (np.ndarray): rotation matrix + """ + + h = np.matmul(p_matrix.transpose(), q_matrix) + u, _, vh = np.linalg.svd(h) + d = np.linalg.det(np.matmul(vh.transpose(), u.transpose())) + int_mat = np.identity(3) + int_mat[2, 2] = d + rot_matrix = np.matmul(np.matmul(vh.transpose(), int_mat), u.transpose()) + + return rot_matrix + + +def get_rot_mat_euler_from_terms( + a: float, b: float, c: float, d: float +) -> np.ndarray: + """3D rotation matrix from terms unique terms in the matrix""" + + aa, bb, cc, dd = a * a, b * b, c * c, d * d + bc, ad, ac, ab, bd, cd = b * c, a * d, a * c, a * b, b * d, c * d + rot_matrix = np.array( + [ + [aa + bb - cc - dd, 2 * (bc + ad), 2 * (bd - ac)], + [2 * (bc - ad), aa + cc - bb - dd, 2 * (cd + ab)], + [2 * (bd + ac), 2 * (cd - ab), aa + dd - bb - cc], + ] + ) + + return rot_matrix + + +def get_rot_mat_euler( + axis: np.ndarray, theta: Union[float, "Angle"] +) -> np.ndarray: + """ + Compute the 3D rotation matrix using the Euler Rodrigues formula + https://en.wikipedia.org/wiki/Euler–Rodrigues_formula + for an anticlockwise rotation of theta radians about a given axis + + --------------------------------------------------------------------------- + Arguments: + axis (np.ndarray): Axis to rotate in. shape = (3,) + theta (float): Angle in radians (float) + + Returns: + (np.ndarray): Rotation matrix. shape = (3, 3) + """ + if hasattr(theta, "to"): + theta = theta.to("rad") + + axis = np.asarray(axis) + axis = axis / np.linalg.norm(axis) # Normalise + + a = np.cos(theta / 2.0) + b, c, d = -axis * np.sin(theta / 2.0) + rot_matrix = get_rot_mat_euler_from_terms(a=a, b=b, c=c, d=d) + + return rot_matrix + + +def get_neighbour_list( + species: "Species", + atom_i: int, + index_set: Sequence[int], +) -> Sequence[int]: + """Calculate a neighbour list from atom i as a list of atom labels + + --------------------------------------------------------------------------- + Arguments: + atom_i (int): index of the atom + + species (autode.species.Species): + + index_set (set(int) | None): Indexes that are possible neighbours for + atom_i, if None then all atoms are ok + + Returns: + (list(int)): list of atom ids in ascending distance away from i + """ + if atom_i not in set(range(species.n_atoms)): + raise ValueError( + f"Cannot get a neighbour list for atom {atom_i} " + f"as it is not in {species.name}, containing " + f"{species.n_atoms} atoms" + ) + + coords = species.coordinates + distance_vector = cdist(np.array([coords[atom_i]]), coords)[0] + + dists_and_atom_labels = {} + for atom_j, dist in enumerate(distance_vector): + if index_set is not None and atom_j not in index_set: + continue + + dists_and_atom_labels[dist] = species.atoms[atom_j].label + + atom_label_neighbour_list = [] + for dist, atom_label in sorted(dists_and_atom_labels.items()): + atom_label_neighbour_list.append(atom_label) + + return atom_label_neighbour_list + + +def calc_heavy_atom_rmsd(atoms1: "Atoms", atoms2: "Atoms") -> float: + """ + Calculate the RMSD between two sets of atoms considering only the 'heavy' + atoms, i.e. the non-hydrogen atoms + + --------------------------------------------------------------------------- + Arguments: + atoms1 (list(autode.atoms.Atom)): + + atoms2 (list(autode.atoms.Atom)): + + Returns: + (float): RMSD between the two sets + """ + if len(atoms1) != len(atoms2): + raise ValueError( + "RMSD must be computed between atom lists of the" + f"same length: {len(atoms1)} =/= {len(atoms2)}" + ) + + coords1 = np.array([atom.coord for atom in atoms1 if atom.label != "H"]) + coords2 = np.array([atom.coord for atom in atoms2 if atom.label != "H"]) + + if len(coords1) == 0 or len(coords2) == 0: + logger.warning("No heavy atoms! assuming a zero RMSD") + return 0.0 + + return calc_rmsd(coords1, coords2) + + +def calc_rmsd(coords1: np.ndarray, coords2: np.ndarray) -> float: + """ + Calculate the RMSD between two sets of coordinates using the Kabsch + algorithm + + --------------------------------------------------------------------------- + Arguments: + coords1 (np.ndarray): shape = (n, 3) + + coords2 (np.ndarray): shape = (n ,3) + + Returns: + (float): Root mean squared distance + """ + assert coords1.shape == coords2.shape + + p_mat = np.array(coords2, copy=True) + p_mat -= np.average(p_mat, axis=0) + + q_mat = np.array(coords1, copy=True) + q_mat -= np.average(q_mat, axis=0) + + rot_mat = get_rot_mat_kabsch(p_mat, q_mat) + + fitted_coords = np.dot(rot_mat, p_mat.T).T + return np.sqrt(np.average(np.square(fitted_coords - q_mat))) + + +def get_points_on_sphere(n_points: int, r: float = 1) -> List[np.ndarray]: + """ + Find n evenly spaced points on a sphere using the "How to generate + equidistributed points on the surface of a sphere" by Markus Deserno, 2004. + + --------------------------------------------------------------------------- + Arguments: + n_points (int): number of points to generate + + r (float): radius of the sphere + + Returns: + (list(np.ndarray)) + """ + points = [] + + a = 4.0 * np.pi * r**2 / n_points + d = np.sqrt(a) + m_theta = int(np.round(np.pi / d)) + d_theta = np.pi / m_theta + d_phi = a / d_theta + + for m in range(m_theta): + theta = np.pi * (m + 0.5) / m_theta + m_phi = int(np.round(2.0 * np.pi * np.sin(theta) / d_phi)) + + for n in range(m_phi): + phi = 2.0 * np.pi * n / m_phi + point = np.array( + [ + r * np.sin(theta) * np.cos(phi), + r * np.sin(theta) * np.sin(phi), + r * np.cos(theta), + ] + ) + + points.append(point) + + return points + + +def symm_matrix_from_ltril( + array: Union[Sequence[float], Sequence[Sequence[float]]] +) -> np.ndarray: + """ + Construct a symmetric matrix from the lower triangular elements e.g.:: + + array = [0, 1, 2] -> array([[0, 1], + [1, 2]]) + + --------------------------------------------------------------------------- + Arguments: + array (list(float) | np.array): + + Returns: + (np.ndarray): + """ + + if len(array) > 0 and type(array[0]) in (list, np.ndarray): + # Flatten the array of arrays + array = [item for sublist in array for item in sublist] # type: ignore + + n = int((np.sqrt(8 * len(array) + 1) - 1) / 2) + + matrix = np.zeros(shape=(n, n), dtype="f8") + + try: + matrix[np.tril_indices(n=n, k=0)] = np.array(array) + + except ValueError: + raise ValueError( + "Array was not the correct shape to be broadcast " + "into the lower triangle. Need N(N+1)/2 elements" + "for an NxN array" + ) + + # Symmetrise by making the upper triangular elements to the lower + lower_idxs = np.tril_indices(n=n, k=-1) + matrix.T[lower_idxs] = matrix[lower_idxs] + + return matrix diff --git a/autodE/source/autode/hessians.py b/autodE/source/autode/hessians.py new file mode 100644 index 0000000000000000000000000000000000000000..396a93e21b6157b6e09d57fac83b52f0b23b07d2 --- /dev/null +++ b/autodE/source/autode/hessians.py @@ -0,0 +1,739 @@ +""" +Hessian diagonalisation and projection routines. See autode/common/hessians.pdf +for mathematical background +""" +import numpy as np +import multiprocessing as mp + +from functools import cached_property +from typing import ( + List, + Tuple, + Iterator, + Optional, + Sequence, + Union, + TYPE_CHECKING, +) +from autode.wrappers.keywords import Functional, GradientKeywords +from autode.log import logger +from autode.config import Config +from autode.constants import Constants +from autode.values import ValueArray, Frequency, Coordinates, Distance +from autode.utils import work_in, hashable, ProcessPool +from autode.units import ( + Unit, + wavenumber, + ha_per_ang_sq, + ha_per_a0_sq, + J_per_m_sq, + J_per_ang_sq, + J_per_ang_sq_kg, +) + +if TYPE_CHECKING: + from autode.wrappers.methods import Method + from autode.atoms import Atoms + from autode.species.species import Species + from autode.wrappers.keywords import GradientKeywords, Keywords + from autode.values import Distance, Gradient + + +class Hessian(ValueArray): + implemented_units = [ + ha_per_ang_sq, + ha_per_a0_sq, + J_per_m_sq, + J_per_ang_sq, + J_per_ang_sq_kg, + ] + + def __repr__(self): + return f"Hessian({np.ndarray.__str__(self)} {self.units.name})" + + def __hash__(self): + # NOTE: Required for functools.lru_cache (< Python 3.8) + return hash(str(self)) + + def __new__( + cls, + input_array: np.ndarray, + units: Union[Unit, str] = ha_per_ang_sq, + atoms: Optional["Atoms"] = None, + functional: Optional[Functional] = None, + ) -> "Hessian": + """ + Hessian matrix + + ----------------------------------------------------------------------- + Arguments: + input_array: Hessian matrix + units: Units of the Hessian + atoms: Atoms on which the Hessian has been calculated + functional: Density functional used to derive the frequency scaling + factor + + Raises: + (ValueError): If the atoms are not the correct shape + """ + arr = super().__new__(cls, input_array, units=units) + + if ( + atoms is not None + and (3 * len(atoms), 3 * len(atoms)) != input_array.shape + ): + raise ValueError( + f"Shape mismatch. Expecting " + f"{input_array.shape[0]//3} atoms from the Hessian" + f" shape, but had {len(atoms)}" + ) + + arr.atoms = atoms + arr.functional = functional + + return arr + + @cached_property + def n_tr(self) -> int: + """ + 5 for a linear molecule and 6 otherwise (3 rotation, 3 translation) + + ----------------------------------------------------------------------- + Returns: + (int): Number of translational and rotational normal modes + + Raises: + (ValueError): Without atoms set + """ + if self.atoms is None or not hasattr(self.atoms, "are_linear"): + raise ValueError( + "Could not determine the number of translations" + "and rotations. Atoms must be set" + ) + + return 5 if self.atoms.are_linear() else 6 + + @cached_property + def n_v(self) -> int: + """ + 3N-6 for a non-linear molecule with N atoms + + ----------------------------------------------------------------------- + Returns: + (int): Number of vibrational normal modes + + Raises: + (ValueError): Without atoms set + """ + if self.atoms is None: + raise ValueError( + "Could not determine the number of vibrations." + " Atoms must be set" + ) + + return 3 * len(self.atoms) - self.n_tr + + def _tr_vecs( + self, + ) -> Tuple[ + np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray + ]: + """ + Orthonormal translation and rotation (tr) vectors for Hessian + projection. + + ----------------------------------------------------------------------- + Returns: + (tuple(np.ndarray)): + + Raises: + (RecursionError): If an orthogonal set cannot be constructed + """ + n_atoms = len(self.atoms) + + e_x = np.array([1.0, 0.0, 0.0]) + e_y = np.array([0.0, 1.0, 0.0]) + e_z = np.array([0.0, 0.0, 1.0]) + + t1 = np.tile(e_x, reps=n_atoms) + t2 = np.tile(e_y, reps=n_atoms) + t3 = np.tile(e_z, reps=n_atoms) + + com = self.atoms.com # Centre of mass + t4, t5, t6 = [], [], [] + + for atom in self.atoms: + r = atom.coord - com + t4 += np.cross(e_x, r).tolist() + t5 += np.cross(e_y, r).tolist() + t6 += np.cross(e_z, r).tolist() + + return t1, t2, t3, np.array(t4), np.array(t5), np.array(t6) + + @cached_property + def _proj_matrix(self) -> np.ndarray: + """ + Construct the projection matrix to transform the Hessian into block + diagonal form:: + + H => ( 0 0) + (0 H') + + F = D^T H D + + Method from: + https://chemistry.stackexchange.com/questions/74639/how-to-calculate + -wavenumbers-of-normal-modes-from-the-eigenvalues-of-the-cartesi + + see common/hessians.tex for methods + + ----------------------------------------------------------------------- + Returns: + (np.ndarray): Transform matrix (D) + """ + if self.atoms is None: + raise ValueError("Could generate projected Hessian. Atoms not set") + + t1, t2, t3, t4, t5, t6 = self._tr_vecs() + + # Construct M^1/2, which as it's diagonal, is just the roots of the + # diagonal elements + masses = np.repeat([atom.mass for atom in self.atoms], repeats=3) + m_half = np.sqrt(masses) + + for t_i in (t1, t2, t3, t4, t5, t6): + t_i *= m_half + + tr_bas = np.array([t1, t2, t3, t4, t5, t6]).transpose() + U_s, s_v, _ = np.linalg.svd(tr_bas) + + if self.atoms.are_linear() and s_v[5] / s_v[0] > 1e-4: + logger.warning( + "Molecule detected as linear, but lowest singular" + f" value from rotation vectors is {s_v[5]:.2e}" + ) + + return U_s + + @cached_property + def _mass_weighted(self) -> np.ndarray: + """Mass weighted the Hessian matrix + + H_ij + H'_ij = ------------ + √(m_i x m_j) + """ + if self.atoms is None: + raise ValueError("Could not calculate frequencies. Atoms not set") + + H = self.to("J ang^-2") + mass_array = np.repeat( + [atom.mass.to("kg") for atom in self.atoms], + repeats=3, + axis=np.newaxis, + ) + + return np.array( + H / np.sqrt(np.outer(mass_array, mass_array)) + ) # J Å^-2 kg^-1 + + @cached_property + def _proj_mass_weighted(self) -> np.ndarray: + """ + Hessian with the translation and rotation projected out with an + orthonormal transformation:: + + H' = T^T H T + + ----------------------------------------------------------------------- + Returns: + (np.ndarray): + """ + H = np.linalg.multi_dot( + (self._proj_matrix.T, self._mass_weighted, self._proj_matrix) + ) + return H + + @cached_property + def normal_modes(self) -> List[Coordinates]: + """ + Calculate the normal modes as the eigenvectors of the Hessian matrix + + ----------------------------------------------------------------------- + Returns: + (list(autode.values.Coordinates)): + + Raises: + (ValueError): If atoms are not set + """ + _, modes = np.linalg.eigh(self._mass_weighted) + + # Convert the eigenvectors from the columns of the matrix into + # individual displacements that can be added to a set of coordinates + return [Coordinates(mode / np.linalg.norm(mode)) for mode in modes.T] + + @cached_property + def normal_modes_proj(self) -> List[Coordinates]: + """ + Normal modes from the projected Hessian without rotation or translation + + ----------------------------------------------------------------------- + Returns: + (list(autode.values.Coordinates)): + """ + if self.atoms is None: + raise ValueError( + "Could not calculate projected normal modes, must" + " have atoms set" + ) + + n_tr = self.n_tr # Number of translational+rotational modes + n_v = self.n_v # and the number of vibrations + + _, S_bar = np.linalg.eigh(self._proj_mass_weighted[n_tr:, n_tr:]) + + # Re-construct the block matrix + S_prime = np.block( + [ + [np.zeros((n_tr, n_tr)), np.zeros((n_tr, n_v))], + [np.zeros((n_v, n_tr)), S_bar], + ] + ) + + # then apply the back-transformation + modes = [] + for i in range(n_tr + n_v): + mode = np.dot(self._proj_matrix, S_prime[:, i]) + + # only normalise the vibrations as the rotations/translations are 0 + if i >= n_tr: + mode /= np.linalg.norm(mode) + + modes.append(Coordinates(mode)) + + return modes + + @property + def _freq_scale_factor(self) -> float: + """Determine the correct frequency scale factor""" + + if Config.freq_scale_factor is not None: + return Config.freq_scale_factor + + if self.functional is not None: + return self.functional.freq_scale_factor + + return 1.0 + + def _eigenvalues_to_freqs(self, lambdas) -> List[Frequency]: + """ + Convert eigenvalues of the Hessian matrix (SI units) to + frequencies in wavenumber units. Will use ade.Config.freq_scale_factor + to scale the frequencies. + + ----------------------------------------------------------------------- + Arguments: + lambdas (np.ndarray): + + Returns: + (list(autode.values.Frequency)): + """ + + nus = np.sqrt(np.complex128(lambdas)) / ( + 2.0 * np.pi * Constants.ang_to_m * Constants.c_in_cm + ) + nus *= self._freq_scale_factor + + # Cast the purely complex eigenvalues to negative real numbers, as is + # usual in quantum chemistry codes + idx_to_alter = np.iscomplex(nus) + nus[idx_to_alter] = -np.abs(nus[idx_to_alter]) + + return [Frequency(np.real(nu), units=wavenumber) for nu in nus] + + @cached_property + def frequencies(self) -> List[Frequency]: + """ + Calculate the normal mode frequencies from the eigenvalues of the + Hessian matrix + + ----------------------------------------------------------------------- + Returns: + (list(autode.values.Frequency)): + + Raises: + (ValueError): Without atoms set + """ + lambdas = np.linalg.eigvalsh(self._mass_weighted) + freqs = self._eigenvalues_to_freqs(lambdas) + + return freqs + + @cached_property + def frequencies_proj(self) -> List[Frequency]: + """ + Frequencies with rotation and translation projected out + + ----------------------------------------------------------------------- + Returns: + (list(autode.values.Frequency)): + + Raises: + (ValueError): Without atoms set + """ + if self.atoms is None: + raise ValueError( + "Could not calculate projected frequencies, must " + "have atoms set" + ) + n_tr = self.n_tr # Number of translational+rotational modes + + H = self._proj_mass_weighted + norms = np.linalg.norm(H, axis=0) + max_norm = np.max(norms) + n_zeroed_modes = 0 # Number of modes that have been well projected out of the hessian + for norm in norms: + if norm / max_norm > 0.1 or n_zeroed_modes == n_tr: + break + else: + n_zeroed_modes += 1 + + if n_zeroed_modes != n_tr: + logger.warning( + f"Number of well zeroed eigenvectors of the hessian " + f"was [{n_zeroed_modes}] should be [{n_tr}]" + ) + + lambdas = np.linalg.eigvalsh(H[n_tr:, n_tr:]) + trans_rot_freqs = [Frequency(0.0) for _ in range(n_tr)] + vib_freqs = self._eigenvalues_to_freqs(lambdas) + return trans_rot_freqs + vib_freqs + + def copy(self, *args, **kwargs) -> "Hessian": + return self.__class__( + np.copy(self), units=self.units, atoms=self.atoms + ) + + +class NumericalHessianCalculator: + def __init__( + self, + species: "Species", + method: "Method", + keywords: "GradientKeywords", + do_c_diff: bool, + shift: Distance, + n_cores: Optional[int] = None, + ): + self._species = species + self._method = method + self._keywords = self._validated(keywords) + + self._do_c_diff = do_c_diff + self._shift = shift.to("Å") + + self._hessian = Hessian( + np.zeros(shape=self._hessian_shape), + units="Ha Å^-2", + atoms=species.atoms.copy(), + ) + + self._calculated_rows: List[int] = [] + + self._n_total_cores = Config.n_cores if n_cores is None else n_cores + + @work_in("numerical_hessian") + def calculate(self) -> None: + """Calculate the Hessian""" + + logger.info( + f"Calculating a numerical Hessian " + f'{"with" if self._do_c_diff else "without"} central ' + f"differences using {self._n_total_cores} total cores.\n" + f"Doing: {self._n_rows * (2 if self._do_c_diff else 1)} " + f"gradient evaluations" + ) + + if not self._do_c_diff: + logger.info("Calculating gradient at current point") + self._init_gradient = self._gradient(species=self._species) + + # if run in a child process, do serial calculation + if mp.parent_process() is not None: + return self._calculate_in_serial() + + # Although n_rows may be < n_cores there will not be > n_rows processes + with ProcessPool(max_workers=self._n_total_cores) as pool: + func_name = "_cdiff_row" if self._do_c_diff else "_diff_row" + + jobs = [ + pool.submit(hashable(func_name, self), i, k) + for (i, k) in self._idxs_to_calculate() + ] + + for row_idx, row in enumerate(jobs): + self._hessian[row_idx, :] = row.result() + + return None + + def _calculate_in_serial(self) -> None: + """Calculate the Hessian rows in serial""" + + for row_idx, (i, k) in enumerate(self._idxs_to_calculate()): + row = ( + self._cdiff_row(i, k) + if self._do_c_diff + else self._diff_row(i, k) + ) + self._hessian[row_idx, :] = row + + return None + + @property + def hessian(self) -> Hessian: + """Hessian matrix of {d^2E/dX_ij^2}. Must be symmetric""" + + arr = np.array(self._hessian, copy=True) + self._hessian[:] = (arr + arr.T) / 2.0 + + return self._hessian + + @property + def _hessian_shape(self) -> Tuple[int, int]: + """Shape of the Hessian matrix for the species""" + return 3 * self._species.n_atoms, 3 * self._species.n_atoms + + @property + def _n_rows(self) -> int: + """Number of rows in the Hessian""" + return 3 * self._species.n_atoms + + @property + def _n_cores_pp(self) -> int: + """Number of cores per process to use e.g. a 6x6 Hessian with + Config.n_cores = 12 -> _n_cores = 2""" + return max(self._n_total_cores // self._n_rows, 1) + + def _new_species(self, atom_idx: int, component: int, direction: str): + """ + New species with an applied shift to an atom. For example, water_0x+ + for a water molecule where the 0th atom has been shifted in the + positive x direction + """ + assert direction in ("+", "-") # Positive or negative + + species = self._species.new_species() + + c = ["x", "y", "z"][component] + species.name = f"{self._species.name}_{atom_idx}{c}{direction}" + + vec = self._shift_vector(component=component) + species.atoms[atom_idx].translate(vec if direction == "+" else -vec) + + return species + + def _idxs_to_calculate(self) -> Iterator: + """Generate the indexes of atoms and cartesian components that + need to be calculated""" + + for row_idx in range(self._n_rows): + if row_idx not in self._calculated_rows: + self._calculated_rows.append(row_idx) + + atom_idx = row_idx // 3 + component = row_idx % 3 # 0: x, 1: y, 2: z + + yield atom_idx, component + + return + + @staticmethod + def _validated( + keywords: "Keywords", + ) -> "GradientKeywords": + """Validate the keywords""" + + if not isinstance(keywords, GradientKeywords): + raise ValueError( + "Numerical Hessian require the keywords to be " + "GradientKeywords" + ) + + if keywords.contain_any_of("hess", "freq", "hessian", "frequency"): + raise ValueError( + "Cannot calculate numerical Hessian with keywords" + " that contain Hess or Freq. Must be only grad" + ) + + return keywords + + def _shift_vector(self, component: int) -> np.ndarray: + """Vector to shift an atom by along a defined Cartesian component, + where, for example, component=0 -> x translation in +h direction""" + + vec = np.zeros(shape=(3,)) + vec[component] += float(self._shift) + + return vec + + def _gradient(self, species) -> "Gradient": + """Evaluate the flat gradient, with shape = (3 n_atoms,)""" + from autode.calculations import Calculation + + calc = Calculation( + name=species.name, + molecule=species, + method=self._method, + keywords=self._keywords, + n_cores=self._n_cores_pp, + ) + calc.run() + return species.gradient.flatten() + + @property + def _init_gradient(self) -> "np.ndarray": + """Gradient at the initial geometry of the species""" + return np.array(self._species.gradient).flatten() + + @_init_gradient.setter + def _init_gradient(self, value): + """Set the initial gradient""" + self._species.gradient = value.reshape(self._species.n_atoms, 3) + + def _cdiff_row(self, atom_idx, component) -> np.ndarray: + """Calculate a Hessian row with central differences""" + + s_plus = self._new_species(atom_idx, component, direction="+") + s_minus = self._new_species(atom_idx, component, direction="-") + + row = (self._gradient(s_plus) - self._gradient(s_minus)) / ( + 2 * self._shift + ) + + return row + + def _diff_row(self, atom_idx, component) -> np.ndarray: + """Calculate a Hessian row with one-sided differences""" + + s_plus = self._new_species(atom_idx, component, direction="+") + + row = (self._gradient(s_plus) - self._init_gradient) / self._shift + + return row + + +class HybridHessianCalculator(NumericalHessianCalculator): + """ + Calculator for a numerical Hessian evaluated at two levels of + theory. One fast low level method to generate an estimate of the full + Hessian, then one slow method used to evaluate numerical derivatives + for only a few atoms. For example, + + .. code-block:: Python + + >>> import autode as ade + >>> + >>> water = ade.Molecule(smiles='O') + >>> dx = ade.values.Distance(0.001, units='Å') + >>> calculator = ade.hessians.HybridHessianCalculator(water, + idxs=(0,), + shift=dx) + >>> calculator.calculate() + """ + + def __init__( + self, + species: "Species", + idxs: Sequence[int], + shift: "Distance", + lmethod: Optional["Method"] = None, + hmethod: Optional["Method"] = None, + n_cores: Optional[int] = None, + ): + """ + Initialise a two-level numerical Hessian calculation using a low-level + method (lmethod) and a high-level method (hmethod) for only some atoms, + with indexes (idxs) + + ----------------------------------------------------------------------- + Arguments: + species: Species to evaluate the Hessian for + + idxs: Atom indices, the displacements for which will be calculated + using the high-level method + + shift: Numerical shift in used in the finite differences + + lmethod: Low-level method + + hmethod: High-level method + + n_cores: Number of cores to use, defaults to Config.n_cores + """ + lmethod = _method_or_default_lmethod(lmethod) + + super().__init__( + species=species, + method=lmethod, + keywords=lmethod.keywords.grad, + do_c_diff=False, + shift=shift, + n_cores=n_cores, + ) + + if not set(idxs).issubset(set(range(species.n_atoms))): + raise ValueError( + "Cannot calculate a partial numerical Hessian " + "at least one atom index was not present in the " + "species." + ) + + self._hmethod_atom_idxs = set(idxs) + self._hmethod = _method_or_default_hmethod(hmethod) + + def calculate(self) -> None: + """Calculate the partial numerical Hessian""" + + super().calculate() + + logger.info( + "Switching to high-level method and calculating " + f"displacements for atoms: {self._hmethod_atom_idxs}" + ) + + self._remove_h_method_rows() + self._method = self._hmethod + self._keywords = self._hmethod.keywords.grad + + super().calculate() + return None + + def _remove_h_method_rows(self) -> None: + """ + Remove rows from the Hessian that have been calculated by the + low-level method but need to be calculated by the high-level method + """ + + for atom_idx in self._hmethod_atom_idxs: + for i, _ in enumerate(("x", "y", "z")): + self._calculated_rows.remove(3 * atom_idx + i) + + return None + + +def _method_or_default_hmethod( + method: Optional["Method"], +) -> "Method": + # Avoid cyclic imports + from autode.methods import method_or_default_hmethod + + return method_or_default_hmethod(method) + + +def _method_or_default_lmethod( + method: Optional["Method"], +) -> "Method": + # Avoid cyclic imports + from autode.methods import method_or_default_lmethod + + return method_or_default_lmethod(method) diff --git a/autodE/source/autode/input_output.py b/autodE/source/autode/input_output.py new file mode 100644 index 0000000000000000000000000000000000000000..cfca0ba38dde739f58ec1631dcd936a47bf0bb4d --- /dev/null +++ b/autodE/source/autode/input_output.py @@ -0,0 +1,184 @@ +import os + +from typing import Collection, Sequence, Optional, Any, TYPE_CHECKING + +from autode.atoms import Atom, Atoms +from autode.exceptions import XYZfileDidNotExist +from autode.exceptions import XYZfileWrongFormat +from autode.log import logger +from autode.utils import StringDict + +if TYPE_CHECKING: + from autode.species.molecule import Molecule + from autode.species.species import Species + + +def xyz_file_to_atoms(filename: str) -> Atoms: + """ + From a .xyz file get a list of autode atoms + + --------------------------------------------------------------------------- + Arguments: + filename: .xyz filename + + Raises: + (autode.exceptions.XYZfileWrongFormat): If the file is the wrong format + + Returns: + (autode.atoms.Atoms): Atoms + """ + logger.info(f"Getting atoms from {filename}") + + _check_xyz_file_exists(filename) + atoms = Atoms() + n_atoms = 0 + + for i, line in enumerate(open(filename, "r")): + if i == 0: # First line in an xyz file is the number of atoms + n_atoms = _n_atoms_from_first_xyz_line(line) + continue + elif i == 1: # Second line of an xyz file is the tittle line + continue + elif i == n_atoms + 2: + break + + try: + atom_label, x, y, z = line.split()[:4] + atoms.append(Atom(atomic_symbol=atom_label, x=x, y=y, z=z)) + + except (IndexError, TypeError, ValueError): + raise XYZfileWrongFormat( + f"Coordinate line {i} ({line}) not the correct format" + ) + + if len(atoms) != n_atoms: + raise XYZfileWrongFormat( + f"Number of atoms declared ({n_atoms}) " + f"not equal to the number of atoms found " + f"{len(atoms)}" + ) + + if n_atoms == 0: + raise XYZfileWrongFormat(f"XYZ file ({filename}) had no atoms!") + + return atoms + + +def atoms_to_xyz_file( + atoms: Collection[Atom], + filename: str, + title_line: str = "", + append: bool = False, +): + """ + Print a standard .xyz file from a list of atoms + + --------------------------------------------------------------------------- + Arguments: + atoms: List of autode atoms to print + + filename: Name of the file (with .xyz extension) + + title_line: Second line of the xyz file, can be blank + + append: Do or don't append to this file. With append=False + filename will be overwritten if it already exists + """ + assert atoms is not None + assert filename.endswith(".xyz") + + with open(filename, "a" if append else "w") as xyz_file: + print(len(atoms), title_line, sep="\n", file=xyz_file) + + for atom in atoms: + x, y, z = atom.coord # (Å) + print( + f"{atom.label:<3} {x:10.5f} {y:10.5f} {z:10.5f}", file=xyz_file + ) + return None + + +def xyz_file_to_molecules(filename: str) -> Sequence["Molecule"]: + """ + From a .xyz file containing potentially more than a single molecule + return a list of molecules from it. + + --------------------------------------------------------------------------- + Arguments: + filename: Filename to open + + Returns: + (list(autode.species.molecule.Molecule)): Molecules + """ + from autode.species.molecule import Molecule # prevents circular imports + + _check_xyz_file_exists(filename) + + lines = open(filename, "r").readlines() + n_atoms = int(lines[0].split()[0]) + molecules = [] + + for i in range(0, len(lines), n_atoms + 2): + atoms = [] + title_line = StringDict(lines[i + 1]) + for j, line in enumerate(lines[i + 2 : i + n_atoms + 2]): + symbol, x, y, z = line.split()[:4] + atoms.append(Atom(atomic_symbol=symbol, x=x, y=y, z=z)) + + molecule = Molecule( + atoms=atoms, solvent_name=title_line.get("solvent", None) + ) + + _set_attr_from_title_line(molecule, "charge", title_line) + _set_attr_from_title_line(molecule, "mult", title_line) + _set_attr_from_title_line( + molecule, "energy", title_line, key_in_line="E" + ) + + molecules.append(molecule) + + return molecules + + +def attrs_from_xyz_title_line(filename: str) -> StringDict: + title_line = "" + + for i, line in enumerate(open(filename, "r")): + if i == 1: + title_line = line.strip() + break + + return StringDict(title_line) + + +def _check_xyz_file_exists(filename: str) -> None: + if not os.path.exists(filename): + raise XYZfileDidNotExist(f"{filename} did not exist") + + if not filename.endswith(".xyz"): + raise XYZfileWrongFormat("xyz file must have a .xyz file extension") + + return None + + +def _set_attr_from_title_line( + species: "Species", + attr: str, + title_line: StringDict, + key_in_line: Optional[str] = None, +) -> None: + if key_in_line is None: + key_in_line = attr # Default to e.g. charge attribute is "charge = 0" + try: + setattr(species, attr, title_line[key_in_line]) + except IndexError: + logger.warning(f"Failed to set the species {attr} from xyz file") + + return None + + +def _n_atoms_from_first_xyz_line(line: str) -> int: + try: + return int(line.strip()) + except (IndexError, ValueError): + raise XYZfileWrongFormat("Number of atoms not found") diff --git a/autodE/source/autode/log/__init__.py b/autodE/source/autode/log/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8db58a9e053f5143ec8f429064d3d2b9d357d36f --- /dev/null +++ b/autodE/source/autode/log/__init__.py @@ -0,0 +1,4 @@ +from autode.log.log import logger + + +__all__ = ["logger"] diff --git a/autodE/source/autode/log/log.py b/autodE/source/autode/log/log.py new file mode 100644 index 0000000000000000000000000000000000000000..0bbac83b107ba3a7ce4d6610518b4e3f48077064 --- /dev/null +++ b/autodE/source/autode/log/log.py @@ -0,0 +1,88 @@ +import logging +import os + +""" +Set up logging with the standard python logging module. Set the log level with +$AUTODE_LOG_LEVEL = {'', ERROR, WARNING, INFO, DEBUG} + +i.e. export AUTODE_LOG_LEVEL=DEBUG + +Also, set whether to log to a file with +$AUTODE_LOG_FILE = filename + +""" + + +def get_log_level(): + """ + Get the logger level from the $AUTODE_LOG_LEVEL environment variable + + --------------------------------------------------------------------------- + Returns: + (int): Log level. Default is logging.CRITICAL == 50 + """ + + try: + log_level_str = os.environ["AUTODE_LOG_LEVEL"] + except KeyError: + log_level_str = "" + + if log_level_str == "DEBUG": + return logging.DEBUG + + if log_level_str == "WARNING": + return logging.WARNING + + if log_level_str == "INFO": + return logging.INFO + + if log_level_str == "ERROR": + return logging.ERROR + + return logging.CRITICAL + + +def log_to_log_file(): + """ + Should the log be piped into a file? Looks for $AUTODE_LOG_FILE being + set and writes logs to that file. Also possible to pipe directly from + stderr to a file with + + .. code-block: + python script.py 2> ade.log + + --------------------------------------------------------------------------- + Returns: + (bool): + """ + + try: + _ = os.environ["AUTODE_LOG_FILE"] + return True + except KeyError: + return False + + +if log_to_log_file(): + logging.basicConfig( + level=get_log_level(), + filename=os.environ["AUTODE_LOG_FILE"], + filemode="w", + format="%(asctime)s %(name)-12s: %(levelname)-8s " "%(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + +else: + logging.basicConfig( + level=get_log_level(), + format="%(name)-12s: %(levelname)-8s %(message)s", + ) +logger = logging.getLogger(__name__) + +# Try and use colourful logs... +try: + import coloredlogs + + coloredlogs.install(level=get_log_level(), logger=logger) +except ImportError: + pass diff --git a/autodE/source/autode/log/methods.py b/autodE/source/autode/log/methods.py new file mode 100644 index 0000000000000000000000000000000000000000..fdad3b09c2ba7d9fdb57e204f2171ca1e83c74e5 --- /dev/null +++ b/autodE/source/autode/log/methods.py @@ -0,0 +1,45 @@ +class ComputationalMethods: + """Generic computational methods section built by autodE""" + + def __init__(self): + """ + ComputationalMethods as a list of sentences and digital object + identifiers (DOIs) for all the methods used + """ + self._list = [] + + def __add__(self, other): + """Add another sentence to the methods if it's not already present""" + assert type(other) is str + + if other not in self._list: + self._list.append(other) + + return None + + def __repr__(self): + return self.__str__() + + def __str__(self): + """String of the computational methods used in this initialisation + of autodE prepended with the current version""" + import autode + + autode_str = ( + f"All calculations were performed in autodE " + f"v. {autode.__version__} (10.1002/anie.202011941). " + ) + + return autode_str + " ".join(self._list) + + def add(self, other): + """Add a string to the methods""" + return self.__add__(other) + + def clear(self): + """Clear the current string""" + self._list = [] + return None + + +methods = ComputationalMethods() diff --git a/autodE/source/autode/methods.py b/autodE/source/autode/methods.py new file mode 100644 index 0000000000000000000000000000000000000000..8a4f1ac5e30bf3c157ede47d47f0e39b05d3a2c7 --- /dev/null +++ b/autodE/source/autode/methods.py @@ -0,0 +1,152 @@ +from typing import Optional, TYPE_CHECKING + +from autode.wrappers.G09 import G09 +from autode.wrappers.G16 import G16 +from autode.wrappers.NWChem import NWChem +from autode.wrappers.ORCA import ORCA +from autode.wrappers.QChem import QChem +from autode.wrappers.MOPAC import MOPAC +from autode.wrappers.XTB import XTB +from autode.log import logger +from autode.config import Config +from autode.exceptions import MethodUnavailable + +if TYPE_CHECKING: + from autode.wrappers.methods import Method + +""" +Functions to get the high and low level electronic structure methods to use +for example high-level methods would be orca and Gaussian09 which can perform +DFT/WF theory calculations, low level methods are, for example, xtb and mopac +which are fast non ab-initio methods +""" + +high_level_method_names = ["orca", "g09", "g16", "nwchem", "qchem"] +low_level_method_names = ["xtb", "mopac"] + + +def method_or_default_lmethod(method: Optional["Method"]) -> "Method": + """ + Return a method if one is defined but default to a low-level method if + if it is None. + + --------------------------------------------------------------------------- + Arguments: + method: Method or None + + Returns: + (autode.wrappers.base.ElectronicStructureMethod): Method + """ + if method is None: + method = get_lmethod() + logger.info(f"Using the default low-level method {method}") + + return method + + +def method_or_default_hmethod(method: Optional["Method"]) -> "Method": + """ + Return a method if one is defined but default to a high-level method if + if it is None. + + --------------------------------------------------------------------------- + Arguments: + method: Method or None + + Returns: + (autode.wrappers.base.ElectronicStructureMethod): Method + """ + if method is None: + method = get_hmethod() + logger.info(f"Using the default high-level method {method}") + + return method + + +def get_hmethod() -> "Method": + """Get the 'high-level' electronic structure theory method to use + + --------------------------------------------------------------------------- + Returns: + (Method): High-level method + """ + + h_methods = [ORCA(), G09(), NWChem(), G16(), QChem()] + + if Config.hcode is not None: + return get_defined_method(name=Config.hcode, possibilities=h_methods) + else: + return get_first_available_method(h_methods) + + +def get_lmethod() -> "Method": + """Get the 'low-level' electronic structure theory method to use + + Returns: + (Method): Low-level method + """ + + all_methods = [XTB(), MOPAC(), ORCA(), G16(), G09(), NWChem(), QChem()] + + if Config.lcode is not None: + return get_defined_method(name=Config.lcode, possibilities=all_methods) + else: + return get_first_available_method(all_methods) + + +def get_first_available_method( + possibilities, +) -> "Method": + """ + Get the first electronic structure method that is available in a list of + possibilities. + + --------------------------------------------------------------------------- + Arguments: + possibilities (list(autode.wrappers.base.ElectronicStructureMethod)): + + Returns: + (Method): Method + + Raises: + (autode.exceptions.MethodUnavailable): + """ + for method in possibilities: + if method.is_available: + return method + + raise MethodUnavailable("No electronic structure methods available") + + +def get_defined_method(name, possibilities) -> "Method": + """ + Get an electronic structure method defined by it's name. + + --------------------------------------------------------------------------- + Arguments: + name (str): + possibilities (list(autode.wrappers.base.ElectronicStructureMethod)): + + Returns: + (Method): Method + + Raises: + (autode.exceptions.MethodUnavailable): + """ + + for method in possibilities: + if method.name.lower() == name.lower(): + if method.is_available: + return method + + else: + err_str = ( + f"Electronic structure method *{name}* is not " + f"available. Check that {method.name} exists in a " + f"directory present in $PATH, or set " + f"ade.Config.{method.__class__.__name__}.path" + ) + + raise MethodUnavailable(err_str) + + raise MethodUnavailable("Requested code does not exist") diff --git a/autodE/source/autode/mol_graphs.py b/autodE/source/autode/mol_graphs.py new file mode 100644 index 0000000000000000000000000000000000000000..521036a90c1954fc0d25a28c691d19dc41ece431 --- /dev/null +++ b/autodE/source/autode/mol_graphs.py @@ -0,0 +1,845 @@ +import itertools +import networkx as nx +import numpy as np + +from copy import deepcopy +from networkx.algorithms import isomorphism +from typing import Optional, List, Tuple, TYPE_CHECKING +from autode.utils import timeout +import autode.exceptions as ex +from scipy.spatial import distance_matrix +from autode.atoms import Atom, metals +from autode.log import logger + +if TYPE_CHECKING: + from autode.values import Distance + from autode.species.species import Species + + +class MolecularGraph(nx.Graph): + def __repr__(self): + return ( + f"MolecularGraph(|E| = {self.number_of_edges()}, " + f"|V| = {self.number_of_nodes()})" + ) + + @property + def expected_planar_geometry(self) -> bool: + """ + Is the 3D structure of the molecule corresponding to this graph + expected to be planar? + + ----------------------------------------------------------------------- + Returns: + (bool): + """ + + for node in self.nodes: + n_neighbours = len(list(self.neighbors(node))) + + if n_neighbours < 4: + # 1, 2 and 3-valent atoms must be planar + continue + + if n_neighbours == 4 and self.nodes[node]["atom_label"] in metals: + # Metals e.g. Rh can be square planar + continue + + return False + + return True + + @property + def eqm_bond_distance_matrix(self) -> np.ndarray: + """ + An n_atoms x n_atoms matrix of ideal bond lengths. All non-bonded atoms + will have zero ideal bond lengths + + ----------------------------------------------------------------------- + Returns: + (np.ndarray): Matrix of bond lengths (Å) + """ + logger.info("Getting ideal bond length matrix") + + n_atoms = self.number_of_nodes() + matrix = np.zeros((n_atoms, n_atoms)) + + for i, j in self.edges: + r0 = float(self._covalent_radius(i) + self._covalent_radius(j)) + matrix[i, j] = matrix[j, i] = r0 + + return matrix + + def _covalent_radius(self, i) -> "Distance": + """Covalent radius of a node in the graph""" + return Atom(self.nodes[i]["atom_label"]).covalent_radius.to("Å") + + def is_isomorphic_to(self, other: "MolecularGraph") -> bool: + """Is this graph isomorphic to another?""" + + return is_isomorphic(self, other) + + @property + def active_bonds(self) -> List[Tuple[int, int]]: + """ + Extract the active bonds from the graph into a flat list of pairs + of atom indices + """ + return [ + (i, j) for (i, j) in self.edges if self.edges[(i, j)]["active"] + ] + + def add_active_edge(self, u: int, v: int) -> None: + """ + Add an 'active' edge between two atoms, where an active edge is one + that is made or broken in a reaction + """ + logger.info("Getting molecular graph with active edges") + + # The graph has both (i, j) and (j, i) edges thus order invariant + if (u, v) in self.edges: + self.edges[(u, v)]["active"] = True + else: + self.add_edge(u, v, pi=False, active=True) + + _set_graph_attributes(self) + return None + + @property + def node_matcher(self): + """Default node matcher""" + + matcher = isomorphism.categorical_node_match( + attr=["atom_label", "atom_class"], default=["C", None] + ) + + return matcher + + @property + def is_connected(self) -> bool: + """Is this graph fully connected (i.e. not separate parts)""" + return nx.is_connected(self) + + def connected_components(self): + """Generate the separate connected components""" + return nx.connected_components(self) + + +def make_graph( + species: "Species", + rel_tolerance: float = 0.3, + bond_list: Optional[List[tuple]] = None, + allow_invalid_valancies: bool = False, +) -> None: + """ + Make the molecular graph from the 'bonds' determined on a distance criteria + or a SMILES parser object. All attributes default to false:: + + Nodes attributes; + (0) atom_label: Atomic symbol of this atom + (1) stereo: Is this atom part of some stereochemistry e.g. R/S or + E/Z + + Edge attributes; + (1) pi: Is this bond a pi bond. If it is then there should be no + rotation the bond axis in conformer generation + (2) active: Is this bond being made/broken + (applies only to TransitionState objects) + + + --------------------------------------------------------------------------- + Arguments: + species: + + rel_tolerance: Relative tolerance on what is considered a bond. E.g + 0.3 means anything 1.3 x r0(i, j) is not 'bonded'. + Subject to the other restrictions on valency + + bond_list: Explicit bonds between atoms, overriding any attempt to + evaluate bonds + + allow_invalid_valancies: Should invalid atomic valencies be allowed? + If false then e.g. a carbon atom with 5 atoms + close enough to be bonded will have only 4 bonds + + Raises: + NoAtomsInMolecule: + """ + + if species.n_atoms == 0: + raise ex.NoAtomsInMolecule( + "Could not build a molecular graph with no " "atoms" + ) + + logger.info("Generating molecular graph with NetworkX") + + graph = MolecularGraph() + + # Add the atoms to the graph all are initially assumed not to be + # stereocenters + for i, atom in enumerate(species.atoms): + graph.add_node( + i, atom_label=atom.label, stereo=False, atom_class=atom.atom_class + ) + + if bond_list is not None: + logger.debug( + f"Bonds have been specified. Adding {len(bond_list)} edges" + ) + for i, j in bond_list: + graph.add_edge(i, j, pi=False, active=False) + + species.graph = graph + return None + + # Loop over the unique pairs of atoms and add 'bonds' + coords = species.coordinates + dist_mat = distance_matrix(coords, coords) + + # Enumerate atoms low->high atomic weight for consistent graph generation + for i, _ in enumerate( + sorted(species.atoms, key=lambda _atom: _atom.weight) + ): + # Iterate through the closest atoms to atom i + for j in np.argsort(dist_mat[i]): + if i == j: # Don't bond atoms to themselves + continue + + # Get r_avg for this X-Y bond e.g. C-C -> 1.5 Å + avg_bond_length = species.atoms.eqm_bond_distance(i, j) + + # If the distance between atoms i and j are less or equal to + # 1.25x average length add a 'bond' + if ( + dist_mat[i, j] <= avg_bond_length * (1.0 + rel_tolerance) + and (i, j) not in graph.edges + ): + graph.add_edge(i, j, pi=False, active=False) + + _set_graph_attributes(graph) + species.graph = graph + + if not allow_invalid_valancies: + remove_bonds_invalid_valancies(species) + + return None + + +def remove_bonds_invalid_valancies(species): + """ + Remove invalid valencies for atoms that exceed their maximum valencies e.g. + H should have no more than 1 'bond' + + --------------------------------------------------------------------------- + Arguments: + species (autode.species.Species): + """ + + for i in species.graph.nodes: + max_valance = species.atoms[i].maximal_valance + neighbours = list(species.graph.neighbors(i)) + + if len(neighbours) <= max_valance: + continue # All is well + + logger.warning(f"Atom {i} exceeds its maximal valence removing edges") + + # Get the atom indexes sorted by the closest to atom i + closest_atoms = sorted( + neighbours, key=lambda k: species.distance(i, k) + ) + + # Delete all the bonds to atom(s) j that are above the maximal valance + for j in closest_atoms[max_valance:]: + species.graph.remove_edge(i, j) + + return None + + +def _set_graph_attributes(graph): + """ + For a molecular species set the π bonds and stereocentres in the molecular + graph. + """ + logger.info("Setting graph attributes, inc. the π bonds") + + def is_idx_pi_atom(i): + return Atom(graph.nodes[i]["atom_label"]).is_pi( + valency=graph.degree[i] + ) + + for bond in graph.edges: + atom_i, atom_j = bond + + if all(is_idx_pi_atom(i) for i in bond): + graph.edges[atom_i, atom_j]["pi"] = True + + logger.info("Setting the stereocentres in a species") + # List of atom indexes that are rings in the species + rings = find_cycles(graph) + + for i, j in graph.edges: + if graph.edges[(i, j)]["pi"] is False: + continue + + if any(i in ring for ring in rings): + # The ring should define the stereochemistry of this pi bond + continue + + if _is_stereo_pi_bond(graph, bond=(i, j)): + graph.nodes[i]["stereo"] = True + graph.nodes[j]["stereo"] = True + + for i in graph.nodes: + if _is_chiral_atom(graph, atom_index=int(i)): + graph.nodes[i]["stereo"] = True + + return None + + +def union(graphs): + """Return the union of two graphs. The disjoint union is returned""" + if len(graphs) == 0: + return MolecularGraph() + + return nx.disjoint_union_all(graphs) + + +def species_are_isomorphic(species1, species2): + """ + Check if two complexes are isomorphic in at least one of their conformers + + --------------------------------------------------------------------------- + Arguments: + species1 (autode.species.Species): + + species2 (autode.species.Species): + + Returns: + (bool): + """ + logger.info( + f"Checking if {species1.name} and {species2.name} are " f"isomorphic" + ) + + if species1.graph is None or species2.graph is None: + raise ex.NoMolecularGraph + + if is_isomorphic(species1.graph, species2.graph): + return True + + if species1.n_conformers == species2.n_conformers == 0: + logger.warning("Cannot check for isomorphic species conformers") + return False + + # Conformers don't necessarily have molecular graphs, so make them all + logger.disabled = True + + for species in (species1, species2): + if species.n_conformers == 0: + continue + + for conformer in species.conformers: + make_graph(conformer) + + logger.disabled = False + + # Check on all the pairwise combinations of species conformers looking for + # an isomorphism + def conformers_or_self(_species): + """If there are no conformers for this species return itself otherwise + the list of conformers""" + if _species.n_conformers == 0: + return [_species] + + return _species.conformers + + # Check on all pairs of conformers between the two species + for conformer1 in conformers_or_self(species1): + for conformer2 in conformers_or_self(species2): + if is_isomorphic(conformer1.graph, conformer2.graph): + return True + + return False + + +def graph_matcher(graph1: MolecularGraph, graph2: MolecularGraph): + """ + Generate a networkX graph matcher between two graphs, matching on atom + types and active bonds + + --------------------------------------------------------------------------- + Arguments: + graph1 (nx.Graph): + + graph2 (nx.Graph): + + Returns: + (nx.GraphMatcher) + """ + # Match on active edges too, with the default being false + edge_match = isomorphism.categorical_edge_match("active", False) + + gm = isomorphism.GraphMatcher( + graph1, graph2, node_match=graph1.node_matcher, edge_match=edge_match + ) + return gm + + +def is_subgraph_isomorphic( + larger_graph: MolecularGraph, smaller_graph: MolecularGraph +): + """ + Is the smaller graph subgraph isomorphic to the larger graph? + + --------------------------------------------------------------------------- + Arguments: + larger_graph (nx.Graph): + + smaller_graph (nx.Graph): + + Returns: + (bool) + """ + logger.info("Running subgraph isomorphism") + + gm = graph_matcher(larger_graph, smaller_graph) + if gm.subgraph_is_isomorphic(): + return True + + return False + + +def get_mapping_ts_template( + larger_graph: MolecularGraph, smaller_graph: MolecularGraph +): + """ + Find the mapping for a graph onto a TS template (smaller). Can raise + StopIteration with no match! + + --------------------------------------------------------------------------- + Arguments: + larger_graph (nx.Graph): + + smaller_graph (nx.Graph): + + Returns: + (dict): Mapping + """ + logger.info("Getting mapping of molecule onto the TS template") + + gm = graph_matcher(larger_graph, smaller_graph) + + return next(gm.match()) + + +def get_mapping(graph1, graph2): + """ + Get a sorted mapping of nodes between two graphs + + --------------------------------------------------------------------------- + Arguments: + graph1 (nx.Graph): + + graph2 (nx.Graph): + + Returns: + (dict) + """ + logger.info("Running isomorphism") + + node_match = isomorphism.categorical_node_match("atom_label", "C") + gm = isomorphism.GraphMatcher(graph1, graph2, node_match=node_match) + + try: + mapping = next(gm.match()) + except StopIteration: + raise ex.NoMapping + + return {i: mapping[i] for i in sorted(mapping)} + + +def reorder_nodes(graph, mapping): + """ + Reorder the nodes in a graph using a mapping. NetworkX uses the inverse + mapping so the dict is swapped before the nodes are relabeled + + --------------------------------------------------------------------------- + Arguments: + graph (nx.Graph): + + mapping (dict): + + Returns: + (nx.Graph) + """ + return nx.relabel_nodes( + graph, mapping={u: v for v, u in mapping.items()}, copy=True + ) + + +def get_graph_no_active_edges(graph): + """ + Get a molecular graph without the active edges + + --------------------------------------------------------------------------- + Arguments: + graph (nx.Graph): + + Returns: + (nx.Graph): + """ + + graph_no_ae = graph.copy() + active_edges = [ + edge for edge in graph.edges if graph.edges[edge]["active"] is True + ] + + for i, j in active_edges: + graph_no_ae.remove_edge(i, j) + + return graph_no_ae + + +def get_graphs_ignoring_active_edges(graph1, graph2): + """ + Remove any active edges that are in either graph1 or graph2 from both + graphs + + --------------------------------------------------------------------------- + Arguments: + graph1 (nx.Graph): + + graph2 (nx.Graph): + + Returns: + (tuple(nx.Graph)) + """ + g1, g2 = graph1.copy(), graph2.copy() + + # Iterate through the pairs removing any active edges from both ga and gb + for ga, gb in [(g1, g2), (g2, g1)]: + for edge in ga.edges: + if ga.edges[edge]["active"] is False: + continue + + i, j = edge + ga.remove_edge(i, j) + + if (i, j) in gb.edges: + gb.remove_edge(i, j) + + return g1, g2 + + +@timeout(seconds=5, return_value=False) +def is_isomorphic( + graph1: MolecularGraph, + graph2: MolecularGraph, + ignore_active_bonds: bool = False, +) -> bool: + """Check whether two NX graphs are isomorphic. Contains a timeout because + the gm.is_isomorphic() method occasionally gets stuck + + --------------------------------------------------------------------------- + Arguments: + graph1: + + graph2: + + ignore_active_bonds (bool): + + Returns: + (bool): if the graphs are isomorphic + """ + + if ignore_active_bonds: + graph1, graph2 = get_graphs_ignoring_active_edges(graph1, graph2) + + if not isomorphism.faster_could_be_isomorphic(graph1, graph2): + return False + + # Always match on atom types + node_matcher = graph1.node_matcher + + if ignore_active_bonds: + gm = isomorphism.GraphMatcher(graph1, graph2, node_match=node_matcher) + + else: + # Also match on edges + edge_match = isomorphism.categorical_edge_match("active", False) + gm = isomorphism.GraphMatcher( + graph1, graph2, node_match=node_matcher, edge_match=edge_match + ) + + return gm.is_isomorphic() + + +def gm_is_isomorphic(gm, result): + result[0] = gm.is_isomorphic() + + +def find_cycles(graph): + """Finds all the cycles in a graph + + --------------------------------------------------------------------------- + Arguments: + graph (nx.Graph): the molecular graph + + Returns: + list(list): each list has the atoms in a cycle + """ + return nx.cycle_basis(graph) + + +def connected_components(graph): + """Connected sections of the nx.Graph""" + return list(nx.connected_components(graph)) + + +def reac_graph_to_prod_graph(reac_graph, bond_rearrang): + """Makes the graph of the product from the reactant and the bond + rearrangement, so it has the indices of the reactant + + --------------------------------------------------------------------------- + Arguments: + reac_graph (nx.Graph): Graph of the reactant + bond_rearrang (autode.bond_rearrangement.BondRearrangement): The bond + rearrangement linking reactants and products + + Returns: + (nx.Graph): Graph of the product with each atom indexed as in the + reactants + """ + prod_graph = deepcopy(reac_graph) + + for fbond in bond_rearrang.fbonds: + prod_graph.add_edge(*fbond) + + for bbond in bond_rearrang.bbonds: + prod_graph.remove_edge(*bbond) + + return prod_graph + + +def get_separate_subgraphs(graph): + """ + Find all the unconnected graphs in a graph + + --------------------------------------------------------------------------- + Arguments: + graph (nx.Graph): graph + + Returns: + list: list of graphs separate graphs + """ + return [graph.subgraph(c).copy() for c in nx.connected_components(graph)] + + +def split_mol_across_bond(graph, bond): + """Gets a list of atoms on either side of a bond. Should be separable + into two graphs + + --------------------------------------------------------------------------- + Arguments: + graph (nx.Graph): Molecular graph + + bond (tuple(int)): Bond to be split across e.g. (0, 1) + + Returns: + (list(list(int))): List of atom indexes (as a list of integers) + """ + graph_copy = graph.copy() + + graph_copy.remove_edge(*bond) + split_subgraphs = get_separate_subgraphs(graph_copy) + + if len(split_subgraphs) != 2: + raise ex.CannotSplitAcrossBond + + return [list(graph.nodes) for graph in split_subgraphs] + + +def get_bond_type_list(graph): + """ + Finds the types (i.e CH) of all the bonds in a molecular graph + + --------------------------------------------------------------------------- + Arguments: + graph (nx.Graph): Molecular graph + + Returns: + bond_list_dict (dict): key = bond type, value = list of bonds of this + type + """ + bond_list_dict = {} + atom_types = set() + + for _, atom_label in graph.nodes.data("atom_label"): + atom_types.add(atom_label) + + ordered_atom_labels = sorted(atom_types) + + for index, atom_label in enumerate(ordered_atom_labels): + for i in range(index, len(ordered_atom_labels)): + key = atom_label + ordered_atom_labels[i] + bond_list_dict[key] = [] + + for bond in graph.edges: + atom_i_label = graph.nodes[bond[0]]["atom_label"] + atom_j_label = graph.nodes[bond[1]]["atom_label"] + key1, key2 = atom_i_label + atom_j_label, atom_j_label + atom_i_label + + if key1 in bond_list_dict.keys(): + bond_list_dict[key1].append(bond) + elif key2 in bond_list_dict.keys(): + bond_list_dict[key2].append(bond) + + return bond_list_dict + + +def get_fbonds(graph, key): + """ + Get all the possible forming bonds of a certain type + + --------------------------------------------------------------------------- + Arguments: + graph (nx.Graph): graph object of a molecule + + key (str): string representing the bond type to be examined + + Returns: + list: list of bonds that can be made of this type + """ + possible_fbonds = [] + bonds = list(graph.edges) + for i in graph.nodes: + for j in graph.nodes: + if i > j: + continue + + if not (i, j) in bonds and not (j, i) in bonds: + bond = (i, j) + label_i = graph.nodes[bond[0]]["atom_label"] + label_j = graph.nodes[bond[1]]["atom_label"] + + key1, key2 = label_i + label_j, label_j + label_i + + if key1 == key or key2 == key: + possible_fbonds.append(bond) + + return possible_fbonds + + +def get_truncated_active_mol_graph(graph, active_bonds=None): + """ + Generate a truncated graph of a graph that only contains the active bond + atoms and their nearest neighbours + + --------------------------------------------------------------------------- + Arguments: + graph (nx.Graph): + + active_bonds (list(tuple(int)): + """ + + if active_bonds is None: + # Molecular graph may already define the active edges + active_bonds = [ + pair for pair in graph.edges if graph.edges[pair]["active"] + ] + + if len(active_bonds) == 0: + raise ValueError( + "Could not generate truncated active molecular " + "graph with no active bonds" + ) + + t_graph = MolecularGraph() + + # Add all nodes that connect active bonds + for bond in active_bonds: + for idx in bond: + if idx not in t_graph.nodes: + label = graph.nodes[idx]["atom_label"] + t_graph.add_node(idx, atom_label=label) + + t_graph.add_edge(*bond, active=True, pi=False) + + # For every active atom add the nearest neighbours + for idx in deepcopy(t_graph.nodes): + neighbours = graph.neighbors(idx) + + # Add nodes and edges for all atoms and bonds to the neighbours that + # don't already exist in the graph + for n_atom_index in neighbours: + if n_atom_index not in t_graph.nodes: + label = graph.nodes[n_atom_index]["atom_label"] + t_graph.add_node(n_atom_index, atom_label=label) + + if (idx, n_atom_index) not in t_graph.edges: + t_graph.add_edge(idx, n_atom_index, pi=False, active=False) + + logger.info( + f"Truncated graph generated. {t_graph.number_of_nodes()} " + f"nodes and {t_graph.number_of_edges()} edges" + ) + return t_graph + + +def _is_stereo_pi_bond(graph, bond): + """Determine if a pi bond is chiral, by seeing if either atom has the same + group bonded to it twice""" + + for i, atom in enumerate(bond): + neighbours = list(graph.neighbors(atom)) + neighbours.remove(bond[1 - i]) + + if len(neighbours) != 2: + return False + + graphs = [] + for neighbour in neighbours: + graph = graph.copy() + graph.remove_edge(atom, neighbour) + split_subgraphs = get_separate_subgraphs(graph) + graphs.append( + [ + subgraph + for subgraph in split_subgraphs + if neighbour in list(subgraph.nodes()) + ][0] + ) + + if is_isomorphic(graphs[0], graphs[1], ignore_active_bonds=True): + return False + + return True + + +def _is_chiral_atom(graph, atom_index): + """Determine if an atom is chiral, by seeing if any of the bonded groups + are the same""" + neighbours = list(graph.neighbors(atom_index)) + + if len(neighbours) != 4: + return False + + graphs = [] + for neighbour in neighbours: + _graph = graph.copy() + _graph.remove_edge(atom_index, neighbour) + split_subgraphs = get_separate_subgraphs(_graph) + graphs.append( + [ + subgraph + for subgraph in split_subgraphs + if neighbour in list(subgraph.nodes()) + ][0] + ) + + for graph1, graph2 in itertools.combinations(graphs, 2): + if is_isomorphic(graph1, graph2, ignore_active_bonds=True): + return False + + return True diff --git a/autodE/source/autode/neb/__init__.py b/autodE/source/autode/neb/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..016450abf602b5e5bf5621482e6343f8bd9d52aa --- /dev/null +++ b/autodE/source/autode/neb/__init__.py @@ -0,0 +1,4 @@ +from autode.neb.original import NEB +from autode.neb.ci import CINEB + +__all__ = ["NEB", "CINEB"] diff --git a/autodE/source/autode/neb/ci.py b/autodE/source/autode/neb/ci.py new file mode 100644 index 0000000000000000000000000000000000000000..5c30e1506dd1d56ced97c64454fc753d1fa29759 --- /dev/null +++ b/autodE/source/autode/neb/ci.py @@ -0,0 +1,125 @@ +""" +Climbing image (CI) nudged elastic band implementation from +https://doi.org/10.1063/1.1329672 +""" +import numpy as np +from scipy.optimize import OptimizeResult +from typing import Optional, Any + +from autode.neb.original import NEB, Images, Image +from autode.log import logger +from autode.values import ForceConstant + + +class CImage(Image): + def __init__(self, image: Image): + """ + Construct a climbing image from a non-climbing one + + ----------------------------------------------------------------------- + Arguments: + image (autode.neb.Image): + """ + super().__init__(species=image, name=image.name, k=image.k) + # Set all the current attributes from the regular image + self.__dict__.update(image.__dict__) + + def get_force(self, im_l: Image, im_r: Image) -> np.ndarray: + """ + Compute F_m + + ----------------------------------------------------------------------- + Arguments: + im_l (autode.neb.Image): Left image (i-1) + im_r (autode.neb.Image): Right image (i+1) + """ + assert self.gradient is not None, "Image force requires a gradient" + + # τ, x_i-1, x_i, x_i+1 + hat_tau, x_l, x, x_r = self._tau_xl_x_xr(im_l, im_r) + + # F_m = ∇V(x_m) + (2∇V(x_m).τ)τ + return -self.gradient + 2.0 * np.dot(self.gradient, hat_tau) * hat_tau + + +class CImages(Images): + def __init__( + self, + images: Images, + wait_iterations: int = 4, + init_k: Optional[ForceConstant] = None, + ): + """ + Initialise a set of images + + ---------------------------------------------------------------------- + Arguments: + images (autode.neb.Images): + + wait_iterations (int): Number of iterations to wait before turning + on the climbing image + + init_k: Initial force constant. Must be defined if the images are empty + """ + super().__init__( + init_k=init_k if init_k is not None else images.init_k, + ) + + self.wait_iteration = wait_iterations + for i, image in enumerate(images): + self[i] = image + + def __eq__(self, other): + """Equality of climbing image NEB""" + if ( + not isinstance(other, CImages) + or self.wait_iteration != other.wait_iteration + ): + return False + + return super().__eq__(other) + + def increment(self) -> None: + """Increment the counter, and switch on a climbing image""" + super().increment() + + if self[0].iteration < self.wait_iteration: + # No need to do anything else + return + + if self.peak_idx is None: + logger.error("Lost NEB peak - cannot switch on CI") + return + + logger.info(f"Setting image {self.peak_idx} as the CI") + self[self.peak_idx] = CImage(image=self[self.peak_idx]) + return None + + +class CINEB(NEB): + _images_type = CImages + + def __init__(self, *args: Any, **kwargs: Any): + super().__init__(*args, **kwargs) + + self.images: CImages = CImages(self.images) + + def _minimise(self, method, n_cores, etol, max_n=30) -> OptimizeResult: + """Minimise the energy of every image in the NEB""" + logger.info(f"Minimising to ∆E < {etol:.4f} Ha on all NEB coordinates") + result = super()._minimise(method, n_cores, etol, max_n) + + if any( + im.iteration > self.images.wait_iteration for im in self.images + ): + return result + + logger.info( + "Converged before CI was turned on. Reducing the wait and " + "minimising again" + ) + + self.images.wait_iteration = max(im.iteration for im in self.images) + result = super()._minimise(method, n_cores, etol, max_n) + + return result diff --git a/autodE/source/autode/neb/idpp.py b/autodE/source/autode/neb/idpp.py new file mode 100644 index 0000000000000000000000000000000000000000..2633e0f6e6f16bc7eacd30b5925735e264064c14 --- /dev/null +++ b/autodE/source/autode/neb/idpp.py @@ -0,0 +1,172 @@ +import numpy as np + +from scipy.spatial import distance_matrix +from typing import TYPE_CHECKING + +from autode.values import PotentialEnergy + +if TYPE_CHECKING: + from autode.neb.original import Image, Images + + +class IDPP: + """ + Image dependent pair potential (IDPP) objective function from + https://arxiv.org/pdf/1406.1512.pdf + + .. math:: + + S = Σ_i Σ_{j>i} w(r_{ij}) (r_{ij}^{(k)} - r_{ij})^2 + + where :math:`r_{ij}` is the distance between atoms i and j and + :math:`r_{ij}^{(k)} = r_{ij}^{(1)} + k(r_{ij}^{(N)} - r_{ij}^{(1)})/N` for + :math:`N` images. The weight function is :math:`w(r_{ij}) = r_{ij}^{-4}`, + as suggested in the paper. + """ + + def __init__(self, images: "Images"): + """Initialise a IDPP potential from a set of NEB images""" + + if len(images) < 2: + raise ValueError("Must have at least 2 images for IDPP") + + # Distance matrices containing all r_{ij}^k + self._dists = {image_k.name: None for image_k in images} + self._diagonal_distance_matrix_idxs = None + + self._set_distance_matrices(images) + + def __call__(self, image: "Image") -> PotentialEnergy: + r""" + Value of the IDPP objective function for a single image defined by, + + .. math:: + + S_k = 0.5 Σ_i Σ_{j \ne i} w(r_{ij}) (r_{ij}^{(k)} - r_{ij})^2 + + where :math:`i` and :math:`j` enumerate over atoms for an image indexed + by :math:`k`. + + ----------------------------------------------------------------------- + Arguments: + image: NEB image (k) + + Returns: + (float): :math:`S_k` + """ + r_k, r = self._req_distance_matrix(image), self._distance_matrix(image) + w = self._weight_matrix(image) + + return PotentialEnergy(0.5 * np.sum(w * (r_k - r) ** 2)) + + def grad(self, image: "Image") -> np.ndarray: + r""" + Gradient of the potential with respect to displacement of + the Cartesian components: :math:`\nabla S = (dS/dx_0, dS/dy_0, dS/dz_0, + dS/dx_1, ...)` where the numbers denote different atoms. For example, + + .. math:: + + \frac{dS}{dx_0} = -2 \sum_{i \ne j} + \left[2(c-r_{ij})r_{ij}^{-6} + + w(r_{ij})r_{ij}^{-1}) + \right](c - r_{ij})(x_0 - x_j) + + where :math:`c = r_{ij}^{(k)}`. + + ----------------------------------------------------------------------- + Arguments: + image: NEB image (k) + + Returns: + (np.ndarray): :math:`\nabla S` + """ + + x = np.array(image.coordinates).flatten() + grad = np.zeros_like(x) + + r = self._distance_matrix(image, unity_diagonal=True) + w = self._weight_matrix(image) + r_k = self._req_distance_matrix(image) + + a = -2 * (2 * (r_k - r) ** 2 * r ** (-6) + w * (r_k - r) * r ** (-1)) + + """ + The following numpy operations are the same as: + ----------------------------------------------------------------------- + x = x.reshape((-1, 3)) + grad = np.zeros_like(x) + + for i in range(n_atoms): + for j in range(n_atoms): + + if i != j: + grad[i, :] += a[i, j] * (x[i, :] - x[j, :]) + ----------------------------------------------------------------------- + """ + + a[self._diagonal_distance_matrix_idxs] = 0.0 + delta = np.subtract.outer(x, x) + + grad[0::3] = np.sum(a * delta[0::3, 0::3], axis=1) # x + grad[1::3] = np.sum(a * delta[1::3, 1::3], axis=1) # y + grad[2::3] = np.sum(a * delta[2::3, 2::3], axis=1) # z + + return grad.reshape((-1, 3)) + + def _set_distance_matrices(self, images: "Images") -> None: + """ + For each image determine the optimum distance matrix using + + .. math:: + + r_{ij}^{(k)} = r_{ij}^{(1)} + k (r_{ij}^{(N)} - r_{ij}^{(1)}) / N + + and set the the diagonal indices of each distance matrix. + """ + + dist_mat_1 = self._distance_matrix(image=images[0]) + dist_mat_n = self._distance_matrix(image=images[-1]) + + delta = dist_mat_n - dist_mat_1 + n = len(images) + + for k, image in enumerate(images): + self._dists[image.name] = dist_mat_1 + k * delta / n + + self._diagonal_distance_matrix_idxs = np.diag_indices_from(delta) + return None + + def _req_distance_matrix(self, image: "Image"): + """Required distance matrix for an image, with elements r_{ij}^k""" + return self._dists[image.name] + + def _distance_matrix( + self, image: "Image", unity_diagonal: bool = False + ) -> np.ndarray: + """Distance matrix for an image""" + + x = image.coordinates + r = distance_matrix(x, x) + + if unity_diagonal: + r[self._diagonal_distance_matrix_idxs] = 1.0 + + return r + + def _weight_matrix(self, image: "Image") -> np.ndarray: + r""" + Weight matrix with elements + + .. math:: + + w_{ij} = 1/r_{ij}^4 + + + for :math:`i \ne j` otherwise :math:`w_{ii} = 0` + """ + r = self._distance_matrix(image, unity_diagonal=True) + w = r ** (-4.0) + w[self._diagonal_distance_matrix_idxs] = 0.0 # Zero w_ii elements + + return w diff --git a/autodE/source/autode/neb/neb.py b/autodE/source/autode/neb/neb.py new file mode 100644 index 0000000000000000000000000000000000000000..a1dc6506ff5016868bb30d0a8d206a905a971758 --- /dev/null +++ b/autodE/source/autode/neb/neb.py @@ -0,0 +1,70 @@ +import autode.exceptions as ex + +from autode.config import Config +from autode.log import logger +from autode.neb.ci import CINEB +from autode.transition_states.ts_guess import TSguess +from autode.utils import work_in + +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from autode.species.species import Species + from autode.wrappers.methods import Method + + +def get_ts_guess_neb( + reactant: "Species", + product: "Species", + method: "Method", + name: str = "neb", + n: int = 10, +) -> Optional[TSguess]: + """ + Get a transition state guess using a nudged elastic band calculation. The + geometry of the reactant is used as the fixed initial point and the final + product geometry generated by driving a linear path to products, which is + used as the initial guess for the NEB images + + --------------------------------------------------------------------------- + Arguments: + reactant (autode.species.Species): + + product (autode.species.Species): + + method (autode.wrappers.methods.Method): + + name (str): + + n (int): Number of images to use in the NEB + + Returns: + (autode.transition_states.ts_guess.TSguess | None): + """ + assert n is not None + logger.info("Generating a TS guess using a nudged elastic band") + + neb = CINEB.from_end_points(reactant, product, num=n) + + @work_in(name) + def calculate(): + return neb.calculate(method=method, n_cores=Config.n_cores) + + try: + calculate() + except ex.CalculationException: + logger.error("NEB calculation failed") + return None + + if neb.peak_species is None: + logger.error( + "Failed to find a peak in the NEB. Cannot create a TS guess" + ) + return None + + return TSguess( + atoms=neb.peak_species.atoms, + reactant=reactant, + product=product, + name=name, + ) diff --git a/autodE/source/autode/neb/original.py b/autodE/source/autode/neb/original.py new file mode 100644 index 0000000000000000000000000000000000000000..58060ddf9f57c733a56ed80c39d1534850635ee3 --- /dev/null +++ b/autodE/source/autode/neb/original.py @@ -0,0 +1,797 @@ +""" +The theory behind this original NEB implementation is taken from +Henkelman and H. J ́onsson, J. Chem. Phys. 113, 9978 (2000) +""" +import numpy as np + +from typing import Optional, Sequence, List, Any, TYPE_CHECKING, Union, Type +from copy import deepcopy + +from autode.log import logger +from autode.calculations import Calculation +from autode.wrappers.methods import Method +from autode.species.species import Species +from autode.input_output import xyz_file_to_molecules +from autode.path import Path +from autode.utils import work_in, ProcessPool +from autode.config import Config +from autode.neb.idpp import IDPP +from scipy.optimize import minimize +from autode.values import Distance, PotentialEnergy, ForceConstant + +if TYPE_CHECKING: + from autode.wrappers.methods import Method + from autode.neb.ci import CImages + + +def energy_gradient(image, method, n_cores): + """Calculate energies and gradients for an image using a EST method""" + + if isinstance(method, Method): + return _est_energy_gradient(image, method, n_cores) + + elif isinstance(method, IDPP): + return _idpp_energy_gradient(image, method, n_cores) + + raise ValueError( + f"Cannot calculate energy and gradient with {method}." + "Must be one of: ElectronicStructureMethod, IDPP" + ) + + +def _est_energy_gradient(image, est_method, n_cores): + """Electronic structure energy and gradint""" + calc = Calculation( + name=f"{image.name}_{image.iteration}", + molecule=image, + method=est_method, + keywords=est_method.keywords.grad, + n_cores=n_cores, + ) + + @work_in(image.name) + def run(): + calc.run() + + run() + return image + + +def _idpp_energy_gradient( + image: "Image", + idpp: IDPP, + n_cores: int, +) -> "Image": + """ + Evaluate the energy and gradient of an image using an image dependent + pair potential IDDP instance and set the energy and gradient on the image + + --------------------------------------------------------------------------- + Arguments: + image: Image in the NEB + + idpp: Instance + + n_cores: *UNUSED* + + Returns: + (autode.neb.original.Image): Image + """ + image.energy = idpp(image) + image.gradient = idpp.grad(image) + + return image + + +def total_energy(flat_coords, images, method, n_cores, plot_energies): + """Compute the total energy across all images""" + images.set_coords(flat_coords) + + # Number of cores per process is the floored total divided by n images + # minus the two end points that will be fixed + n_cores_pp = 1 + if len(images) > 2: + n_cores_pp = max(int(n_cores // (len(images) - 2)), 1) + + logger.info( + f"Calculating energy and forces for all images with " + f"{n_cores} total cores and {n_cores_pp} per process" + ) + + # Run an energy + gradient evaluation across all images (parallel for EST) + if isinstance(method, IDPP): + images[1:-1] = [ + energy_gradient(images[i], method, n_cores_pp) + for i in range(1, len(images) - 1) + ] + else: + with ProcessPool(max_workers=n_cores) as pool: + results = [ + pool.submit(energy_gradient, images[i], method, n_cores_pp) + for i in range(1, len(images) - 1) + ] + + images[1:-1] = [res.result() for res in results] + + images.increment() + + if plot_energies: + images.plot_energies() + + all_energies = [image.energy for image in images] + rel_energies = [energy - min(all_energies) for energy in all_energies] + + logger.info(f"Path energy = {sum(rel_energies):.5f}") + return sum(rel_energies) + + +def derivative(flat_coords, images, method, n_cores, plot_energies): + """ + Compute the derivative of the total energy with respect to all + components. Several arguments are unused as SciPy requires the jacobian + function to have the same signature as the function that's being minimised. + See: https://tinyurl.com/scipyopt + """ + + # Forces for the first image are fixed at zero + forces = np.zeros(shape=images[0].gradient.shape) + + # No need to calculate gradient as should already be there from energy eval + for i in range(1, len(images) - 1): + force = images[i].get_force(im_l=images[i - 1], im_r=images[i + 1]) + forces = np.append(forces, force) + + # Final zero set of forces + forces = np.append(forces, np.zeros(shape=images[-1].gradient.shape)) + + # dV/dx is negative of the force + logger.info(f"|F| = {np.linalg.norm(forces):.4f} Ha Å-1") + return -forces + + +class Image(Species): + def __init__( + self, + species: Species, + name: str, + k: ForceConstant, + ): + """ + Image in a NEB + + -------------------------------------------------------------------------------- + Arguments: + species (Species): Molecule for which this image represents + + name (str): Name of this image + + k (ForceConstant): Force constant of the harmonic potential joining this + image to its neighbour(s) + """ + super().__init__( + name=name, + charge=species.charge, + mult=species.mult, + atoms=species.atoms.copy(), + ) + self.solvent = deepcopy(species.solvent) + self.energy = deepcopy(species.energy) + + self.iteration = 0 #: Current optimisation iteration of this image + self.k = k + + def _generate_conformers(self, *args, **kwargs): + raise RuntimeError("Cannot create conformers of an image") + + def _tau_xl_x_xr( + self, + im_l: "Image", + im_r: "Image", + ) -> tuple: + """ + Calculate the normalised τ vector, along with the coordinates of the + left, this and right images + + ----------------------------------------------------------------------- + Arguments: + im_l: (autode.neb.Image) + im_r: (autode.neb.Image) + + Returns: + (np.ndarray, np.ndarray, np.ndarray, np.ndarray) + """ + assert self.energy is not None, "Energy must be set to calculate tau" + assert im_l.energy is not None, "Left image energy must be set" + assert im_r.energy is not None, "Right image energy must be set" + + # ΔV_i^max + dv_max = max( + np.abs(im_r.energy - self.energy), + np.abs(im_l.energy - self.energy), + ) + + # ΔV_i^min + dv_min = min( + np.abs(im_r.energy - self.energy), + np.abs(im_l.energy - self.energy), + ) + + # x_i-1, x_i, x_i+1 + x_l, x, x_r = [ + image.coordinates.flatten() for image in (im_l, self, im_r) + ] + # τ_i+ + tau_plus = x_r - x + # τ_i- + tau_minus = x - x_l + + if im_l.energy < self.energy < im_r.energy: + tau = tau_plus + + elif im_r.energy < self.energy < im_l.energy: + tau = tau_minus + + elif im_l.energy < im_r.energy: + tau = tau_plus * dv_max + tau_minus * dv_min + + elif im_r.energy < im_l.energy: + tau = tau_plus * dv_min + tau_minus * dv_max + + else: + raise RuntimeError("Something went very wrong in the NEB!") + + # Normalised τ vector and coordinates of the images + return tau / np.linalg.norm(tau), x_l, x, x_r + + def get_force( + self, + im_l: "Image", + im_r: "Image", + ) -> np.ndarray: + """ + Compute F_i. Notation from: + Henkelman and H. J ́onsson, J. Chem. Phys. 113, 9978 (2000) + + also a copy in autode/common + + ----------------------------------------------------------------------- + Arguments: + im_l (autode.neb.Image): Left image (i-1) + im_r (autode.neb.Image): Right image (i+1) + """ + assert self.gradient is not None, "Gradient must be set to calc force" + + # τ, x_i-1, x_i, x_i+1 + hat_tau, x_l, x, x_r = self._tau_xl_x_xr(im_l, im_r) + + # F_i^s|| + f_parallel = ( + np.linalg.norm(x_r - x) * im_r.k - np.linalg.norm(x - x_l) * im_l.k + ) * hat_tau + + # ∇V(x)_i|_|_ = ∇V(x)_i - (∇V(x)_i•τ) τ + grad_perp = self.gradient - np.dot(self.gradient, hat_tau) * hat_tau + + # F_i = F_i^s|| - ∇V(x)_i|_|_ + return f_parallel - grad_perp + + @property + def gradient(self) -> Optional[np.ndarray]: + return None if self._grad is None else self._grad.flatten() # type: ignore + + @gradient.setter + def gradient(self, value: Optional[np.ndarray]): + self._grad = None if value is None else value.flatten() + + +class Images(Path): + def __init__( + self, + init_k: ForceConstant, + min_k: Optional[ForceConstant] = None, + max_k: Optional[ForceConstant] = None, + ): + """ + Set of images joined by harmonic springs with force constant k + + ----------------------------------------------------------------------- + Arguments: + + init_k (ForceConstant): Initial force constant + + min_k (ForceConstant | None): Minimum value of k + + max_k (ForceConstant | None): Maximum value of k + """ + super().__init__() + + self.init_k = init_k + self.min_k = init_k / 10 if min_k is None else min_k + self.max_k = 2 * init_k if max_k is None else max_k + assert ( + self.max_k > self.min_k + ), "Can't set the min force constant above the max" + + def __eq__(self, other): + """Equality od two climbing image NEB paths""" + if not isinstance(other, Images): + return None + + if any((self.min_k != other.min_k, self.max_k != other.max_k)): + return False + + return super().__eq__(other) + + def increment(self): + """Advance all the iteration numbers on the images to name correctly + also update force constants""" + + for image in self: + image.iteration += 1 + + if Config.adaptive_neb_k and all(im.energy is not None for im in self): + logger.info("Updating force constants") + # Notation from https://doi.org/10.1063/1.1329672 + delta_k = self.max_k - self.min_k + + # E_ref is the maximum energy of the end points + energies = [image.energy for image in self] + e_ref = max(energies[0], energies[-1]) + e_max = max(energies) + + if e_ref == e_max: + logger.warning( + "Cannot adjust k, the reference energy was the " "maximum" + ) + # Return otherwise we'll divide by zero here + return + + for image in self: + if image.energy < e_ref: + image.k = self.min_k + + else: + image.k = self.max_k - delta_k * float( + (e_max - image.energy) / (e_max - e_ref) + ) + return None + + def plot_energies( + self, save=False, name="None", color=None, xlabel="NEB coordinate" + ): + """Plot the NEB surface""" + import matplotlib.pyplot as plt + + blues = plt.get_cmap("Blues") + + color = ( + blues((self[0].iteration + 1) / 20) + if color is None + else str(color) + ) + super().plot_energies(save, name, color, xlabel) + + def coords(self): + """Get a flat array of all components of every atom""" + coords = np.array([]) + for image in self: + coords = np.append(coords, image.coordinates.flatten()) + return coords + + def set_coords(self, coords): + """ + Set the flat array of coordinates to the species in the images + + ----------------------------------------------------------------------- + Arguments: + coords (np.ndarray): shape (num x n x 3,) + """ + + n_atoms = self[0].n_atoms + coords = coords.reshape((len(self), n_atoms, 3)) + + for i, image in enumerate(self): + image.coordinates = coords[i] + + return None + + def append_species(self, species: Species) -> None: + """Add a species to the list of images""" + super().append( + Image(species=species, name=f"{len(self)}", k=self.init_k) + ) + + def copy(self) -> "Images": + return deepcopy(self) + + +class NEB: + _images_type: Union[Type[Images], Type["CImages"]] = Images + + def __init__( + self, + init_k: ForceConstant = ForceConstant(0.1, units="Ha / Å^2"), + **kwargs, + ): + """ + Nudged elastic band (NEB) + + Warning: The initial/final species or those in a species list must have + the same atom ordering. + + ----------------------------------------------------------------------- + Arguments: + init_k: Initial force constant between each image + """ + self._raise_exception_if_any(kwargs) + self._init_k = init_k + self.images = Images(init_k=init_k) + + @property + def init_k(self) -> ForceConstant: + """Initial force constant used to in this NEB""" + return self._init_k + + @classmethod + def from_file( + cls, + filename: str, + init_k: Optional[float] = None, + ) -> "NEB": + """ + Create a nudged elastic band from a .xyz file containing multiple + images. + """ + + molecules = xyz_file_to_molecules(filename) + if init_k is None and all(m.energy is not None for m in molecules): + logger.info( + "Have a set of energies from file. Can adaptively " + "choose a sensible force constant (k)" + ) + + max_de = max( + abs(molecules[i].energy - molecules[i + 1].energy) # type: ignore + for i in range(len(molecules) - 1) + ) + + # TODO: test reasonableness of this function... + # use a shifted tanh to interpolate in [0.005, 0.2005] + init_k = ForceConstant( + 0.1 * (np.tanh((max_de.to("kcal mol-1") - 40) / 20) + 1) + + 0.005, + units="Ha / Å^2", + ) + + if init_k is None: # choose a sensible default + init_k = 0.1 + + logger.info( + f"Using k = {init_k:.6f} Ha Å^-1 as the NEB force constant" + ) + return cls.from_list(molecules, init_k=ForceConstant(init_k)) + + @classmethod + def from_list( + cls, + species_list: Sequence[Species], + init_k: ForceConstant = ForceConstant(0.1, units="Ha / Å^2"), + ) -> "NEB": + """ + Nudged elastic band constructed from list of species + + ----------------------------------------------------------------------- + Arguments: + species_list: Full set of initial images that will form the while NEB + + init_k: Force constant + + Returns: + (NEB): + """ + neb = cls(init_k=init_k) + + for species in species_list: + neb.images.append_species(species) + + logger.info(f"Initialised a NEB with {len(neb.images)} images") + return neb + + @classmethod + def from_end_points( + cls, + initial: Species, + final: Species, + num: int, + init_k: ForceConstant = ForceConstant(0.1, units="Ha / Å^2"), + ) -> "NEB": + """ + Construct a nudged elastic band from only the endpoints. The atomic + ordering must be identical in the initial and final species + + ----------------------------------------------------------------------- + Arguments: + initial: Initial/left-most species in the NEB + + final: Final/right-most species in the NEB + + num: Number of images to create + + init_k: Initial force constant + + Returns: + (NEB): + """ + + if initial.sorted_atomic_symbols != final.sorted_atomic_symbols: + raise ValueError( + "Cannot construct a NEB from species with different atoms" + ) + + neb = cls.from_list( + species_list=cls._interpolated_species(initial, final, n=num), + init_k=init_k, + ) + neb.idpp_relax() + + return neb + + def _minimise(self, method, n_cores, etol, max_n=30) -> Any: + """Minimise the energy of every image in the NEB""" + logger.info(f"Minimising to ∆E < {etol:.4f} Ha on all NEB coordinates") + + result = minimize( + total_energy, + x0=self.images.coords(), + method="L-BFGS-B", + jac=derivative, + args=(self.images, method, n_cores, True), + tol=etol, + options={"maxfun": max_n}, + ) + + logger.info(f"NEB path energy = {result.fun:.5f} Ha, {result.message}") + return result + + def partition( + self, + max_delta: Distance, + distance_idxs: Optional[Sequence[int]] = None, + ) -> None: + """ + Partition this NEB such that there are no distances between images + exceeding max_delta. Will run IDPP (image dependent pair potential) + relaxations on intermediate images. + + ----------------------------------------------------------------------- + Arguments: + max_delta: The maximum allowed max_atoms(|x_k - x_k+1|) where + x_k are the cartesian coordinates of the k-th NEB + image and the maximum is over the atom-wise distance + + distance_idxs: Indexes of atoms used to calculate the max_delta. + If none then all distances are used. For example if + only distance_idxs = [0] then |x_k,0 - x_k+1,0| + will be calculated, where 0 is the atom index and + k is the image index + """ + logger.info("Interpolating") + + assert len(self.images) > 1 + _list = [] + + for i, left_image in enumerate(self.images[:-1]): + right_image = self.images[i + 1] + + n = 2 + sub_neb = NEB.from_end_points(left_image, right_image, num=n) + + while ( + sub_neb._max_atom_distance_between_images(distance_idxs) + > max_delta + ): + try: + sub_neb = NEB.from_end_points( + left_image, right_image, num=n + ) + except RuntimeError: + logger.warning("Failed to IDPP relax the interpolated NEB") + + n += 1 + + for image in sub_neb.images[:-1]: # add all apart from the last + _list.append(image) + + _list.append(self.images[-1]) # end with the last + self.images.clear() + + for image in _list: + self.images.append_species(image) + + logger.info( + f"Partition successful – now have {len(self.images)} " f"images" + ) + return None + + def print_geometries(self, name="neb") -> None: + return self.images.print_geometries(name) + + @staticmethod + def _interpolated_species( + initial: Species, final: Species, n: int + ) -> List[Species]: + """Generate simple interpolated coordinates for these set of images + in Cartesian coordinates""" + + if n < 2: + raise RuntimeError("Cannot interpolated 2 images to <2") + + if n == 2: + return [initial.copy(), final.copy()] + + intermediate_species = [] + + # Interpolate images between the starting point i=0 and end point i=n-1 + for i in range(1, n - 1): + # Use a copy of the starting point for atoms, charge etc. + species: Species = initial.copy() + + # For all the atoms in the species translate an amount so the + # spacing is even between the initial and final points + for j, atom in enumerate(species.atoms): + # Shift vector is final minus current + shift = final.atoms[j].coord - atom.coord + # then an equal spacing is the i-th point in the grid + atom.translate(vec=shift * (i / n)) + + intermediate_species.append(species) + + return [initial.copy()] + intermediate_species + [final.copy()] + + @work_in("neb") + def calculate( + self, + method: "Method", + n_cores: int, + name_prefix: str = "", + etol_per_image: Union[float, PotentialEnergy] = PotentialEnergy( + 0.6, units="kcal mol-1" + ), + ) -> None: + """ + Optimise the NEB using forces calculated from electronic structure + + ----------------------------------------------------------------------- + Arguments: + method: Method used to calculate the energy and gradient. Will + use method.keywords.grad keywords + + n_cores: Number of cores to use for the calculation + + name_prefix: Prefix for the naming of the geometry and plot + generated by this function + + etol_per_image: Energy tolerance per image to use in the L-BFGS-B + minimisation + """ + import matplotlib.pyplot as plt + + self.print_geometries(name=f"{name_prefix}neb_init") + + # Calculate energy on the first and final points as these will not be recalc-ed + for idx in [0, -1]: + energy_gradient(self.images[idx], method=method, n_cores=n_cores) + + if isinstance(etol_per_image, PotentialEnergy): + etol_per_image = float( + etol_per_image.to("Ha") + ) # use float for scipy + + result = self._minimise( + method, n_cores, etol=etol_per_image * len(self.images) + ) + + # Set the optimised coordinates for all the images + self.images.set_coords(result.x) + self.print_geometries(name=f"{name_prefix}neb_optimised") + + # and save the plot + plt.savefig(f"{name_prefix}neb_optimised.pdf") + plt.close() + return None + + @property + def peak_species(self) -> Optional[Species]: + """TS guess species for this NEB: highest energy saddle point""" + if not self.images.contains_peak: + logger.warning("Found no peaks in the NEB") + return None + + assert ( + self.images.peak_idx is not None + ), "Must have a peak index with a peak" + image = self.images[self.images.peak_idx] + + return image.new_species() + + def idpp_relax(self) -> None: + """ + Relax the NEB using the image dependent pair potential + + ----------------------------------------------------------------------- + See Also: + :py:meth:`IDPP ` + """ + logger.info(f"Minimising NEB with IDPP potential") + + images = self.images.copy() + images.min_k = images.max_k = ForceConstant(0.1, units="Ha / Å^2") + idpp = IDPP(images=images) + + for i, image in enumerate(images): + image.energy = idpp(image) + image.gradient = idpp.grad(image) + + # Initial and final images are fixed, with zero gradient + if i == 0 or i == len(images) - 1: + image.gradient[:] = 0.0 + + result = minimize( + total_energy, + x0=images.coords(), + method="L-BFGS-B", + jac=derivative, + args=(images, idpp, Config.n_cores, False), + options={"gtol": 0.01}, + ) + + logger.info(f"IDPP minimisation successful: {result.success}") + + self.images.set_coords(result.x) + return None + + def _max_atom_distance_between_images( + self, idxs: Optional[Sequence[int]] = None + ) -> Distance: + """ + Calculate the maximum atom-atom distance between two consecutive images + """ + if idxs is None: # Use all pairwise distances + idxs = np.arange(self.images[0].n_atoms) + else: + idxs = np.array(idxs) + + overall_max_distance = -np.inf + + for i in range(len(self.images) // 2): + k = 2 * i + x_i = self.images[k].coordinates + x_j = self.images[k + 1].coordinates + + max_distance = np.max(np.linalg.norm(x_i - x_j, axis=1)[idxs]) + if max_distance > overall_max_distance: + overall_max_distance = max_distance + + return overall_max_distance + + @property + def max_atom_distance_between_images(self) -> Distance: + return self._max_atom_distance_between_images(idxs=None) + + @staticmethod + def _raise_exception_if_any(kwargs: dict) -> None: + if len(kwargs) == 0: + return + elif any( + arg in kwargs + for arg in ("initial_species", "final_species", "num") + ): + raise ValueError( + "Cannot construct a NEB. Please use NEB.from_endpoints()" + ) + elif "species_list" in kwargs: + raise ValueError( + "Cannot construct a NEB from a species list. Please use NEB.from_list()" + ) + else: + raise ValueError("Unrecognised keyword argument") diff --git a/autodE/source/autode/opt/__init__.py b/autodE/source/autode/opt/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..c548d010329dc071a7dcd08cf49d6debfadcbe99 --- /dev/null +++ b/autodE/source/autode/opt/__init__.py @@ -0,0 +1,3 @@ +from autode.opt.coordinates.base import OptCoordinates +from autode.opt.coordinates.cartesian import CartesianCoordinates +from autode.opt.coordinates.dic import DIC diff --git a/autodE/source/autode/opt/coordinates/__init__.py b/autodE/source/autode/opt/coordinates/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..36daee459c52dbc49446c17f86d49c89b45093ed --- /dev/null +++ b/autodE/source/autode/opt/coordinates/__init__.py @@ -0,0 +1,3 @@ +from autode.opt.coordinates.base import OptCoordinates +from autode.opt.coordinates.cartesian import CartesianCoordinates +from autode.opt.coordinates.dic import DIC, DICWithConstraints diff --git a/autodE/source/autode/opt/coordinates/_autodiff.py b/autodE/source/autode/opt/coordinates/_autodiff.py new file mode 100644 index 0000000000000000000000000000000000000000..0855a441ef901377673e222ed140f8a8277a13cd --- /dev/null +++ b/autodE/source/autode/opt/coordinates/_autodiff.py @@ -0,0 +1,731 @@ +""" +Automatic differentiation routines in pure Python + +References: +[1] P. Rehner, G. Bauer, Front. Chem. Eng., 2021, 3, 758090 +""" +from typing import Union, Callable, Sequence, Optional +from enum import Enum +from copy import deepcopy +import numpy as np +import math + +numeric = (float, int) +numeric_type = Union[float, int] + + +class DerivativeOrder(Enum): + """Order of derivative""" + + zeroth = 0 + first = 1 + second = 2 + + +def get_differentiable_vars( + values: Sequence[numeric_type], + symbols: Sequence[str], + deriv_order: DerivativeOrder = DerivativeOrder.second, +): + """ + Obtain differentiable variables from a series of numbers + + Args: + values: The values of the variables (numbers) + symbols: List of symbols (strings) of the numbers + deriv_order: Order of differentiation + + Returns: + (list[VectorHyperDual]): A list of hyper dual numbers + """ + assert all(isinstance(sym, str) for sym in symbols) + assert len(symbols) == len(values) + symbols = list(symbols) + + hyperduals = [] + for symbol, value in zip(symbols, values): + var = VectorHyperDual.from_variable( + value, symbol, all_symbols=symbols, order=deriv_order + ) + hyperduals.append(var) + + return hyperduals + + +class VectorHyperDual: + """ + Hyper-dual numbers with vector infinitesimals upto the + second order (i.e., upto second partial derivatives) + """ + + def __init__( + self, + value: numeric_type, + symbols: Sequence[str], + first_der: Optional[np.ndarray] = None, + second_der: Optional[np.ndarray] = None, + ): + """ + Create a vector hyper dual number, i.e. a scalar function + with one or more variables + + Args: + value: The scalar value of the hyper-dual + symbols: A list of unique strings representing the variables + first_der: 1D array of first derivatives against variables + second_der: 2D matrix of second derivatives against variables + """ + assert isinstance(value, numeric) + self._val = float(value) + + assert all(isinstance(sym, str) for sym in symbols) + if len(set(symbols)) != len(list(symbols)): + raise RuntimeError("Symbols must be unique!") + self._symbols = tuple(symbols) + + # load the derivatives with sanity checks + self._first_der: Optional[np.ndarray] = None + self._second_der: Optional[np.ndarray] = None + self._order = DerivativeOrder.zeroth + self._init_deriv_arrays(first_der, second_der) + + def _init_deriv_arrays( + self, first_der: Optional[np.ndarray], second_der: Optional[np.ndarray] + ) -> None: + """ + Initialise the derivative matrices, checking they have the + correct shape, and set the derivative order for this + hyper-dual number + """ + if first_der is None: + return None + assert isinstance(first_der, np.ndarray) + first_der = first_der.flatten() + if not first_der.shape == (self.n_vars,): + raise ValueError( + f"Number of symbols ({self.n_vars}) does not match with" + f" shape of derivative array {first_der.shape}" + ) + self._first_der = first_der.astype(float) + self._order = DerivativeOrder.first + + if second_der is None: + return None + assert isinstance(second_der, np.ndarray) + if not second_der.shape == (self.n_vars, self.n_vars): + raise ValueError( + f"Number of symbols ({self.n_vars}) does not match with" + f" shape of second derivative matrix {first_der.shape}" + ) + self._second_der = second_der.astype(float) + self._order = DerivativeOrder.second + + def __repr__(self): + rstring = f"HyperDual({self.value}" + if self._order in [DerivativeOrder.first, DerivativeOrder.second]: + rstring += f", f'[{self.n_vars}]" + if self._order == DerivativeOrder.second: + rstring += f', f"[{self.n_vars}, {self.n_vars}]' + rstring += ")" + return rstring + + @property + def n_vars(self) -> int: + """Number of variables in this hyper-dual""" + return len(self._symbols) + + def copy(self) -> "VectorHyperDual": + return deepcopy(self) + + def _check_compatible(self, other: "VectorHyperDual") -> None: + """ + Check the compatibility of two VectorHyperDual numbers for + any operation that involves the two. + + Args: + other (VectorHyperDual): + + Raises: + (ValueError): If they are incompatible + """ + if self.n_vars != other.n_vars: + raise ValueError( + "Incompatible number of differentiable variables, " + "cannot perform operation" + ) + if self._symbols != other._symbols: + raise ValueError( + "The differentiable variable symbols do not match, " + "cannot perform operation!" + ) + if self._order != other._order: + raise ValueError("The order of derivative do not match!") + return None + + @property + def value(self) -> float: + """Return the value of the hyper-dual number""" + return self._val + + @value.setter + def value(self, value: float): + assert isinstance(value, numeric) + self._val = float(value) + + @classmethod + def from_variable( + cls, + value: float, + symbol: str, + all_symbols: Sequence[str], + order: DerivativeOrder, + ): + """ + Create a hyper-dual number from one variable, requires + list of symbols and the symbol of this specific variable. + Essentially a variable x can be considered as a scalar function + of a list of variables - 1 * x + 0 * y + 0 * z + ... + + Args: + value: The value of the variable (will be converted to float) + symbol: The symbol of the current variable, must be in all_symbols + all_symbols: List of strings indicating all required variables + order: The order of differentiation to consider + + Returns: + (VectorHyperDual): The hyper-dual representing the variable + """ + assert all(isinstance(sym, str) for sym in all_symbols) + assert isinstance(symbol, str) + assert symbol in all_symbols + + val = float(value) + first_der = None + second_der = None + n = len(all_symbols) + idx = list(all_symbols).index(symbol) + order = DerivativeOrder(order) + + if order == DerivativeOrder.first or order == DerivativeOrder.second: + first_der = np.zeros(shape=n, dtype=float) + first_der[idx] = 1.0 + if order == DerivativeOrder.second: + second_der = np.zeros(shape=(n, n), dtype=float) + + return VectorHyperDual(val, all_symbols, first_der, second_der) + + def differentiate_wrt( + self, + symbol1: str, + symbol2: Union[str, None] = None, + ) -> Optional[float]: + """ + Derivative of this hyper-dual number (scalar function) against one + or two variable(s) identified by their string(s). + + Args: + symbol1 (str): + symbol2 (str|None): + + Returns: + (float|None): The derivative value, or None if not available + """ + assert isinstance(symbol1, str) + if symbol1 not in self._symbols: + return None + + if self._order == DerivativeOrder.zeroth: + return None + + idx_1 = self._symbols.index(symbol1) + assert self._first_der is not None + if symbol2 is None: + return self._first_der[idx_1] + + assert isinstance(symbol2, str) + if symbol2 not in self._symbols: + return None + idx_2 = self._symbols.index(symbol2) + # check if second derivs are available + if self._order == DerivativeOrder.first: + return None + assert self._second_der is not None + return self._second_der[idx_1, idx_2] + + def __add__( + self, other: Union["VectorHyperDual", numeric_type] + ) -> "VectorHyperDual": + """Adding a hyper dual number""" + + if isinstance(other, numeric): + new = self.copy() + new._val += float(other) + return new + + # add to another dual number + elif isinstance(other, VectorHyperDual): + self._check_compatible(other) + + val = self._val + other._val + if self._order == DerivativeOrder.zeroth: + return VectorHyperDual(val, self._symbols) + + assert self._first_der is not None + assert other._first_der is not None + first_der = self._first_der + other._first_der + if self._order == DerivativeOrder.first: + return VectorHyperDual(val, self._symbols, first_der) + + assert self._second_der is not None + assert other._second_der is not None + second_der = self._second_der + other._second_der + return VectorHyperDual(val, self._symbols, first_der, second_der) + + else: + raise TypeError("Unknown type for addition") + + def __radd__(self, other): + """Addition is commutative""" + return self.__add__(other) + + def __neg__(self) -> "VectorHyperDual": + """Unary negative operation""" + new = self.copy() + new._val = -new._val + if self._order == DerivativeOrder.first: + assert new._first_der is not None + new._first_der = -new._first_der + elif self._order == DerivativeOrder.second: + assert new._first_der is not None + assert new._second_der is not None + new._first_der = -new._first_der + new._second_der = -new._second_der + return new + + def __sub__(self, other): + """Subtraction of hyper dual numbers""" + return self.__add__(-other) + + def __rsub__(self, other): + """Reverse subtraction""" + return other + (-self) + + def __mul__(self, other) -> "VectorHyperDual": + """Multiply a hyper dual number with float or another hyper dual""" + if isinstance(other, numeric): + new = self.copy() + new._val *= float(other) + return new + + # Product rule for derivatives, Eqn (24) in ref. [1] + elif isinstance(other, VectorHyperDual): + self._check_compatible(other) + + val = self._val * other._val + if self._order == DerivativeOrder.zeroth: + return VectorHyperDual(val, self._symbols) + + assert self._first_der is not None + assert other._first_der is not None + first_der = ( + self._val * other._first_der + other._val * self._first_der + ) + if self._order == DerivativeOrder.first: + return VectorHyperDual(val, self._symbols, first_der) + + assert self._second_der is not None + assert other._second_der is not None + second_der = ( + self._val * other._second_der + + np.outer(self._first_der, other._first_der) + + np.outer(other._first_der, self._first_der) + + other._val * self._second_der + ) + return VectorHyperDual(val, self._symbols, first_der, second_der) + else: + raise TypeError("Unknown type for multiplication") + + def __rmul__(self, other): + return self.__mul__(other) + + def __truediv__(self, other): + """True division, defined by multiplicative inverse""" + return self.__mul__(DifferentiableMath.pow(other, -1)) + + def __rtruediv__(self, other): + """Reverse true division""" + return DifferentiableMath.pow(self, -1).__mul__(other) + + def __pow__(self, power, modulo=None) -> "VectorHyperDual": + if modulo is not None: + raise NotImplementedError("Modulo inverse is not implemented") + + result = DifferentiableMath.pow(self, power) + assert isinstance(result, VectorHyperDual) + return result + + def __rpow__(self, other): + return DifferentiableMath.pow(other, self) + + @staticmethod + def apply_operation( + num: Union["VectorHyperDual", numeric_type], + operator: Callable[[float], float], + operator_first_deriv: Callable[[float], float], + operator_second_deriv: Callable[[float], float], + ) -> Union["VectorHyperDual", numeric_type]: + """ + Perform an operation on the hyperdual (i.e. apply a scalar function), + also compatible with Python numeric (float/int) types. + + Args: + num: Number that is hyper-dual (or float/int) + operator: Function that returns the value (result) of operation + operator_first_deriv: Should return first derivative of operation + operator_second_deriv: Should return second derivative of operation + + Returns: + (VectorHyperDual|float): The result + """ + # pass through numeric types + if isinstance(num, numeric): + return operator(float(num)) + + assert isinstance(num, VectorHyperDual) + + val = operator(num._val) + + if num._order == DerivativeOrder.zeroth: + return VectorHyperDual(val, num._symbols) + + # Eqn (25) in reference [1] + assert num._first_der is not None + f_dash_x0 = operator_first_deriv(num._val) + first_der = num._first_der * f_dash_x0 + + if num._order == DerivativeOrder.first: + return VectorHyperDual(val, num._symbols, first_der) + + assert num._second_der is not None + second_der = np.outer(num._first_der, num._first_der) * ( + operator_second_deriv(num._val) + ) + second_der += num._second_der * f_dash_x0 + return VectorHyperDual(val, num._symbols, first_der, second_der) + + +class DifferentiableMath: + """ + Class defining math functions that can be used on + hyper dual numbers (i.e. differentiable functions), + as well as standard numeric types (float and int) + """ + + @staticmethod + def sqrt( + num: Union[VectorHyperDual, numeric_type] + ) -> Union[VectorHyperDual, numeric_type]: + """Calculate the square root of a hyperdual number""" + + if isinstance(num, numeric): + assert num > 0 + else: + assert num.value > 0 + + return VectorHyperDual.apply_operation( + num, + operator=lambda x0: math.sqrt(x0), + operator_first_deriv=lambda x0: 1 / (2 * math.sqrt(x0)), + operator_second_deriv=lambda x0: -1 / (4 * math.pow(x0, 3 / 2)), + ) + + @staticmethod + def exp( + num: Union[VectorHyperDual, numeric_type] + ) -> Union[VectorHyperDual, numeric_type]: + """Raise e to the power of num""" + + return VectorHyperDual.apply_operation( + num, + operator=lambda x0: math.exp(x0), + operator_first_deriv=lambda x0: math.exp(x0), + operator_second_deriv=lambda x0: math.exp(x0), + ) + + @staticmethod + def pow( + num: Union[VectorHyperDual, numeric_type], + power: Union[VectorHyperDual, numeric_type], + ) -> Union[VectorHyperDual, numeric_type]: + """Exponentiation of one hyperdual to another""" + + if isinstance(num, numeric) and isinstance(power, numeric): + return math.pow(num, power) + + elif isinstance(num, VectorHyperDual) and isinstance(power, numeric): + if num.value < 0 and isinstance(power, float): + raise AssertionError( + "Math error, can't raise negative number to fractional power" + ) + return VectorHyperDual.apply_operation( + num, + operator=lambda x0: math.pow(x0, power), # type: ignore + operator_first_deriv=lambda x0: power # type: ignore + * math.pow(x0, power - 1), + operator_second_deriv=lambda x0: power # type: ignore + * (power - 1) + * math.pow(x0, power - 2), + ) + + elif isinstance(power, VectorHyperDual) and isinstance( + num, (numeric, VectorHyperDual) + ): + if (isinstance(num, numeric) and num < 0) or ( + isinstance(num, VectorHyperDual) and num.value < 0 + ): + raise AssertionError( + "Only positive numbers can be used with" + " differentiable exponent" + ) + # use identity x^y = e^(y log_x) for x > 0 + return DifferentiableMath.exp(power * DifferentiableMath.log(num)) + + else: + raise TypeError("Unknown type for exponentiation") + + @staticmethod + def log( + num: Union[VectorHyperDual, numeric_type] + ) -> Union[VectorHyperDual, numeric_type]: + """Natural logarithm""" + + if isinstance(num, numeric): + assert num > 0 + else: + assert num.value > 0 + + return VectorHyperDual.apply_operation( + num, + operator=lambda x0: math.log(x0), + operator_first_deriv=lambda x0: 1.0 / x0, + operator_second_deriv=lambda x0: -1.0 / (x0**2), + ) + + @staticmethod + def acos( + num: Union[VectorHyperDual, numeric_type] + ) -> Union[VectorHyperDual, numeric_type]: + """Calculate the arccosine of a hyperdual number""" + + if isinstance(num, VectorHyperDual): + assert -1 < num.value < 1 + else: + assert -1 < num < 1 + + return VectorHyperDual.apply_operation( + num, + operator=lambda x0: math.acos(x0), + operator_first_deriv=lambda x0: -1 / math.sqrt(1 - x0**2), + operator_second_deriv=lambda x0: -x0 + / math.pow(1 - x0**2, 3 / 2), + ) + + @staticmethod + def atan( + num: Union[VectorHyperDual, numeric_type] + ) -> Union[VectorHyperDual, numeric_type]: + """Calculate the arctangent of a hyperdual number""" + + return VectorHyperDual.apply_operation( + num, + operator=lambda x0: math.atan(x0), + operator_first_deriv=lambda x0: 1 / (1 + x0**2), + operator_second_deriv=lambda x0: (-2 * x0) / (x0**2 + 1) ** 2, + ) + + @staticmethod + def atan2( + num_y: Union[VectorHyperDual, numeric_type], + num_x: Union[VectorHyperDual, numeric_type], + ) -> Union[VectorHyperDual, numeric_type]: + """Calculate the arctan2 of two hyper dual numbers""" + if isinstance(num_y, numeric) and isinstance(num_x, numeric): + return math.atan2(num_y, num_x) + + # https://en.wikipedia.org/wiki/Atan2 four overlapping half-planes + def atan2_derivs_x_not_0(y, x): + return DifferentiableMath.atan(y / x) + + def atan2_derivs_x_close_0(y, x): + return -DifferentiableMath.atan(x / y) + + x_val = float(num_x) if isinstance(num_x, numeric) else num_x.value + y_val = float(num_y) if isinstance(num_y, numeric) else num_y.value + res_val = math.atan2(y_val, x_val) + + # when atan2(y,x)->pi/2, x->0 or y/x->inf, use other formula for derivs + if math.isclose(abs(res_val), math.pi / 2, abs_tol=0.1): + res = atan2_derivs_x_close_0(num_y, num_x) + res.value = res_val + return res + else: + res = atan2_derivs_x_not_0(num_y, num_x) + res.value = res_val + return res + + +class DifferentiableVector3D: + """ + Convenience class to represent a 3D vector of differentiable + hyper-dual numbers + """ + + def __init__( + self, items: Sequence[Union["VectorHyperDual", numeric_type]] + ): + """ + Initialise the 3D vector from a list of 3 hyperdual numbers + + Args: + items: A list of 3 hyper-dual numbers + """ + items = list(items) + if len(items) != 3: + raise ValueError("A 3D vector must have only 3 components") + assert all( + isinstance(item, (VectorHyperDual, *numeric)) for item in items + ) + self._data = items + + @staticmethod + def _check_same_type(other) -> None: + """Check that another object is also a 3D differentiable vector""" + if not isinstance(other, DifferentiableVector3D): + raise ValueError("Operation must be done with another 3D vector!") + return None + + def dot( + self, other: "DifferentiableVector3D" + ) -> Union["VectorHyperDual", numeric_type]: + """ + Dot product of two 3D vectors + + Args: + other (DifferentiableVector3D): + + Returns: + (VectorHyperDual): A scalar number (with derivatives) + """ + self._check_same_type(other) + dot: Union[VectorHyperDual, numeric_type] = 0 + for k in range(3): + dot = dot + self._data[k] * other._data[k] + return dot + + def norm(self) -> Union["VectorHyperDual", numeric_type]: + """ + Euclidean (l2) norm of this 3D vector + + Returns: + (VectorHyperDual): A scalar number (with derivatives) + """ + norm = DifferentiableMath.sqrt( + self._data[0] ** 2 + self._data[1] ** 2 + self._data[2] ** 2 + ) + return norm + + def __add__( + self, other: "DifferentiableVector3D" + ) -> "DifferentiableVector3D": + """ + Vector addition in 3D, returns a vector + + Args: + other (DifferentiableVector3D): + + Returns: + (DifferentiableVector3D): + """ + self._check_same_type(other) + return DifferentiableVector3D( + [self._data[k] + other._data[k] for k in range(3)] + ) + + def __neg__(self) -> "DifferentiableVector3D": + """ + Unary negation of a vector, returns another vector + + Returns: + (DifferentiableVector3D): + """ + return DifferentiableVector3D([-self._data[k] for k in range(3)]) + + def __sub__(self, other) -> "DifferentiableVector3D": + """ + Vector subtraction in 3D, defined in terms of addition + and negation + + Args: + other (DifferentiableVector3D): + + Returns: + (DifferentiableVector3D): + """ + return self.__add__(-other) + + def __mul__( + self, other: Union[VectorHyperDual, numeric_type] + ) -> "DifferentiableVector3D": + """ + Multiplication of a 3D vector with a scalar + + Args: + other (VectorHyperDual|float|int): + + Returns: + (DifferentiableVector3D): + """ + assert isinstance(other, numeric) or isinstance(other, VectorHyperDual) + return DifferentiableVector3D( + [self._data[k] * other for k in range(3)] + ) + + def __rmul__(self, other): + """Multiplication of scalar and vector is commutative""" + return self.__mul__(other) + + def __truediv__(self, other: Union[VectorHyperDual, numeric_type]): + """ + Division of a 3D vector with a scalar + + Args: + other (VectorHyperDual|float|int): + + Returns: + (DifferentiableVector3D): + """ + return self.__mul__(1 / other) + + def cross( + self, other: "DifferentiableVector3D" + ) -> "DifferentiableVector3D": + """ + Cross-product of two 3D vectors, produces another vector + + Args: + other (DifferentiableVector3D): + + Returns: + (DifferentiableVector3D): + """ + self._check_same_type(other) + return DifferentiableVector3D( + [ + self._data[1] * other._data[2] + - self._data[2] * other._data[1], + self._data[2] * other._data[0] + - self._data[0] * other._data[2], + self._data[0] * other._data[1] + - self._data[1] * other._data[0], + ] + ) diff --git a/autodE/source/autode/opt/coordinates/base.py b/autodE/source/autode/opt/coordinates/base.py new file mode 100644 index 0000000000000000000000000000000000000000..8b26086524eeadc1d5dae1d7a4f11a275dee8ca8 --- /dev/null +++ b/autodE/source/autode/opt/coordinates/base.py @@ -0,0 +1,471 @@ +# mypy: disable-error-code="has-type" +import numpy as np +from copy import deepcopy +from typing import Optional, Union, Sequence, List, TYPE_CHECKING +from abc import ABC, abstractmethod + +from autode.log import logger +from autode.units import ang, nm, pm, m +from autode.values import ValueArray, PotentialEnergy + +if TYPE_CHECKING: + from autode.units import Unit + from autode.values import Gradient + from autode.hessians import Hessian + from typing import Type + from autode.opt.optimisers.hessian_update import HessianUpdater + + +class OptCoordinates(ValueArray, ABC): + """Coordinates used to perform optimisations""" + + implemented_units = [ang, nm, pm, m] + + def __new__( + cls, + input_array: Union[Sequence, np.ndarray], + units: Union[str, "Unit"], + ) -> "OptCoordinates": + """New instance of these coordinates""" + + arr = super().__new__(cls, np.array(input_array), units) + + arr._e = None # Energy + arr._g = None # Gradient: dE/dX + arr._h = None # Hessian: d2E/dX_idX_j + arr._h_inv = None # Inverse of the Hessian: H^-1 + arr.B = None # Wilson B matrix + arr.B_T_inv = None # Generalised inverse of B + arr.U = np.eye(len(arr)) # Transform matrix + arr.allow_unconverged_back_transform = True # for internal coords + + return arr + + def __array_finalize__(self, obj: "OptCoordinates") -> None: + """See https://numpy.org/doc/stable/user/basics.subclassing.html""" + + # fmt: off + for attr in ( + "units", "_e", "_g", "_h", + "_h_inv", "U", "B", "B_T_inv", + "allow_unconverged_back_transform", + ): + self.__dict__[attr] = getattr(obj, attr, None) + + # fmt: on + return None + + @property + def indexes(self) -> List[int]: + """Indexes of the coordinates in this set""" + return list(range(len(self))) + + @property + def raw(self) -> np.ndarray: + """Raw numpy array of these coordinates""" + return np.array(self, copy=True) + + @property + def e(self) -> Optional[PotentialEnergy]: + """ + Energy + + ----------------------------------------------------------------------- + Returns: + (PotentialEnergy | None): E + """ + return self._e + + @e.setter + def e(self, value): + """Set the energy""" + self._e = None if value is None else PotentialEnergy(value) + + @property + def g(self) -> Optional[np.ndarray]: + r""" + Gradient of the energy + + .. math:: + G = \nabla E + \equiv + \left\{\frac{\partial E}{\partial\boldsymbol{R}_{i}}\right\} + + where :math:`\boldsymbol{R}` are a general vector of coordinates. + + ----------------------------------------------------------------------- + Returns: + (np.ndarray | None): G + """ + return self._g + + @g.setter + def g(self, value: np.ndarray): + """Set the gradient of the energy""" + self._g = value + + @property + def h(self) -> Optional[np.ndarray]: + r""" + Hessian (second derivative) matrix of the energy + + .. math:: + H = \begin{pmatrix} + \frac{\partial^2 E} + {\partial\boldsymbol{R}_{0}\partial\boldsymbol{R}_{0}} + & \cdots \\ + \vdots & \ddots + \end{pmatrix} + + where :math:`\boldsymbol{R}` are a general vector of coordinates. + + ----------------------------------------------------------------------- + Returns: + (np.ndarray | None): H + """ + if self._h is None and self._h_inv is not None: + logger.info("Have H^-1 but no H, calculating H") + self._h = np.linalg.inv(self._h_inv) + + return self._h + + @h.setter + def h(self, value: np.ndarray): + """Set the second derivatives of the energy""" + if not self.h_or_h_inv_has_correct_shape(value): + raise ValueError( + f"Hessian must be an NxN matrix. " + f"Had an array with shape: {value.shape}" + ) + + self._h = value + + @property + def h_inv(self) -> Optional[np.ndarray]: + """ + Inverse of the Hessian matrix + + .. math:: H^{-1} + + ----------------------------------------------------------------------- + Returns: + (np.ndarray | None): H^{-1} + """ + + if self._h_inv is None and self._h is not None: + logger.info( + "Have Hessian but no inverse, so calculating " + "explicit inverse" + ) + self._h_inv = np.linalg.inv(self._h) + + return self._h_inv + + @h_inv.setter + def h_inv(self, value: np.ndarray): + """Set the inverse hessian matrix""" + if not self.h_or_h_inv_has_correct_shape(value): + raise ValueError( + "Inverse Hessian must be an NxN matrix. " + f"Had an array with shape: {value.shape}" + ) + + self._h_inv = value + + def h_or_h_inv_has_correct_shape(self, arr: Optional[np.ndarray]): + """Does a Hessian or its inverse have the correct shape?""" + if arr is None: + return True # None is always valid + + return arr.ndim == 2 and arr.shape[0] == arr.shape[1] == len(self) + + @abstractmethod + def _update_g_from_cart_g(self, arr: Optional["Gradient"]) -> None: + """Update the gradient dE/dR of from a Cartesian (cart) gradient""" + + def update_g_from_cart_g( + self, + arr: Optional["Gradient"], + ) -> None: + """Update the gradient from a Cartesian gradient, zeroing those atoms + that are constrained""" + assert ( + arr is None or len(arr.flatten()) % 3 == 0 + ) # Needs an Nx3 matrix + + return self._update_g_from_cart_g(arr) + + @abstractmethod + def _update_h_from_cart_h(self, arr: Optional["Hessian"]) -> None: + """Update the Hessian from a cartesian Hessian""" + + def update_h_from_cart_h( + self, + arr: Optional["Hessian"], + ) -> None: + """Update the Hessian from a cartesian Hessian with shape 3N x 3N for + N atoms, zeroing the second derivatives if required""" + return self._update_h_from_cart_h(arr) + + def update_h_from_old_h( + self, + old_coords: "OptCoordinates", + hessian_update_types: List["Type[HessianUpdater]"], + ) -> None: + r""" + Update the Hessian :math:`H` from an old Hessian using an update + scheme. Requires the gradient to be set, and the old set of + coordinates with gradient to be available + + Args: + old_coords (OptCoordinates): Old set of coordinates with + gradient and hessian defined + hessian_update_types (list[type[HessianUpdater]]): A list of + hessian updater classes - the first updater that + meets the mathematical conditions will be used + """ + assert self._g is not None + assert isinstance(old_coords, OptCoordinates), "Wrong type!" + assert old_coords._h is not None + assert old_coords._g is not None + idxs = self.active_mol_indexes + + for update_type in hessian_update_types: + updater = update_type( + h=old_coords._h, + s=np.array(self) - np.array(old_coords), + y=self._g - old_coords._g, + subspace_idxs=idxs, + ) + + if not updater.conditions_met: + logger.info(f"Conditions for {update_type} not met") + continue + + new_h = updater.updated_h + assert self.h_or_h_inv_has_correct_shape(new_h) + self._h = new_h + return None + + raise RuntimeError( + "Could not update the Hessian - no suitable update strategies" + ) + + @property + def rfo_shift(self) -> float: + """ + Get the RFO diagonal shift factor λ for the molecular Hessian that + can be applied (H - λI) to obtain the RFO downhill step. The shift + is only calculated in active subspace + + Returns: + (float): The shift parameter + """ + assert self._h is not None + # ignore constraint modes + n, _ = self._h.shape + idxs = self.active_mol_indexes + hess = self._h[:, idxs][idxs, :] + grad = self._g[idxs] + + h_n, _ = hess.shape + # form the augmented Hessian in active subspace + aug_h = np.zeros(shape=(h_n + 1, h_n + 1)) + + aug_h[:h_n, :h_n] = hess + aug_h[-1, :h_n] = grad + aug_h[:h_n, -1] = grad + + # first non-zero eigenvalue + aug_h_lmda = np.linalg.eigvalsh(aug_h) + rfo_lmda = aug_h_lmda[0] + assert abs(rfo_lmda) > 1.0e-10 + return rfo_lmda + + @property + def min_eigval(self) -> float: + """ + Obtain the minimum eigenvalue of the molecular Hessian in + the active space + + Returns: + (float): The minimum eigenvalue + """ + assert self._h is not None + n, _ = self._h.shape + idxs = self.active_mol_indexes + hess = self._h[:, idxs][idxs, :] + + eigvals = np.linalg.eigvalsh(hess) + assert abs(eigvals[0]) > 1.0e-10 + return eigvals[0] + + def pred_quad_delta_e(self, new_coords: np.ndarray) -> float: + """ + Calculate the estimated change in energy at the new coordinates + based on the quadratic model (i.e. second order Taylor expansion) + + Args: + new_coords(np.ndarray): The new coordinates + + Returns: + (float): The predicted change in energy + """ + assert self._g is not None and self._h is not None + + step = np.array(new_coords) - np.array(self) + + idxs = self.active_mol_indexes + step = step[idxs] + grad = self._g[idxs] + hess = self._h[:, idxs][idxs, :] + + pred_delta = np.dot(grad, step) + pred_delta += 0.5 * np.linalg.multi_dot((step, hess, step)) + return pred_delta + + def make_hessian_positive_definite(self) -> None: + """ + Make the Hessian matrix positive definite by shifting eigenvalues + """ + self._h = _ensure_positive_definite(self.h, min_eigenvalue=1.0) + return None + + @abstractmethod + def __repr__(self) -> str: + """String representation of these coordinates""" + + @abstractmethod + def to(self, *args, **kwargs) -> "OptCoordinates": + """Transformation between these coordinates and another type""" + + @abstractmethod + def iadd(self, value: np.ndarray) -> "OptCoordinates": + """Inplace addition of some coordinates""" + + @property + @abstractmethod + def n_constraints(self) -> int: + """Number of constraints in these coordinates""" + + @property + @abstractmethod + def n_satisfied_constraints(self) -> int: + """Number of constraints that are satisfied in these coordinates""" + + @property + @abstractmethod + def active_indexes(self) -> List[int]: + """A list of indexes which are active in this coordinate set""" + + @property + def active_mol_indexes(self) -> List[int]: + """Active indexes that are actually atomic coordinates in the molecule""" + return [i for i in self.active_indexes if i < len(self)] + + @property + @abstractmethod + def inactive_indexes(self) -> List[int]: + """A list of indexes which are non-active in this coordinate set""" + + @property + @abstractmethod + def cart_proj_g(self) -> Optional[np.ndarray]: + """ + The Cartesian gradient with any constraints projected out + """ + + def __eq__(self, other): + """Coordinates can never be identical...""" + return False + + def __setitem__(self, key, value): + """ + Set an item or slice in these coordinates. Clears the current + gradient and Hessian as well as clearing setting the coordinates. + Does NOT check if the current value is close to the current, thus + the gradient and hessian shouldn't be cleared. + """ + + self.clear_tensors() + return super().__setitem__(key, value) + + def __add__(self, other: Union[np.ndarray, float]) -> "OptCoordinates": + """ + Addition of another set of coordinates. Clears the current + gradient vector and Hessian matrix. + + ----------------------------------------------------------------------- + Arguments: + other (np.ndarray): Array to add to the coordinates + + Returns: + (autode.opt.coordinates.OptCoordinates): Shifted coordinates + """ + new_coords = self.copy() + new_coords.clear_tensors() + new_coords.iadd(other) + + return new_coords + + def __sub__(self, other: Union[np.ndarray, float]) -> "OptCoordinates": + """Subtraction""" + return self.__add__(-other) + + def __iadd__(self, other: Union[np.ndarray, float]) -> "OptCoordinates": + """Inplace addition""" + self.clear_tensors() + return self.__add__(other) + + def __isub__(self, other: Union[np.ndarray, float]) -> "OptCoordinates": + """Inplace subtraction""" + return self.__iadd__(-other) + + def clear_tensors(self) -> None: + """ + Helper function for clearing the energy, gradient and Hessian for these + coordinates. Called if the coordinates have been perturbed, making + these quantities not accurate any more for the new coordinates + """ + self._e, self._g, self._h = None, None, None + return None + + def copy(self, *args, **kwargs) -> "OptCoordinates": + return deepcopy(self) + + +def _ensure_positive_definite( + matrix: np.ndarray, min_eigenvalue: float = 1e-10 +) -> np.ndarray: + """ + Ensure that the eigenvalues of a matrix are all >0 i.e. the matrix + is positive definite. Will shift all values below min_eigenvalue to that + value. + + --------------------------------------------------------------------------- + Arguments: + matrix: Matrix to make positive definite + + min_eigenvalue: Minimum value eigenvalue of the matrix + + Returns: + (np.ndarray): Matrix with eigenvalues at least min_eigenvalue + """ + + if matrix is None: + raise RuntimeError( + "Cannot make a positive definite matrix - " "had no matrix" + ) + + lmd, v = np.linalg.eig(matrix) # Eigenvalues and eigenvectors + + if np.all(lmd > min_eigenvalue): + logger.info("Matrix was positive definite") + return matrix + + logger.warning( + "Matrix was not positive definite. " + "Shifting eigenvalues to X and reconstructing" + ) + lmd[lmd < min_eigenvalue] = min_eigenvalue + return np.linalg.multi_dot((v, np.diag(lmd), v.T)).real diff --git a/autodE/source/autode/opt/coordinates/cartesian.py b/autodE/source/autode/opt/coordinates/cartesian.py new file mode 100644 index 0000000000000000000000000000000000000000..7b9c00f7ba05d4e73a2d90fb549ab594ef9724a2 --- /dev/null +++ b/autodE/source/autode/opt/coordinates/cartesian.py @@ -0,0 +1,124 @@ +import numpy as np +from typing import Optional, List, TYPE_CHECKING + +from autode.log import logger +from autode.values import ValueArray +from autode.opt.coordinates.base import OptCoordinates +from autode.opt.coordinates.dic import DIC + +if TYPE_CHECKING: + from autode.values import Gradient + from autode.hessians import Hessian + + +class CartesianCoordinates(OptCoordinates): + """Flat Cartesian coordinates shape = (3 × n_atoms, )""" + + def __repr__(self): + return f"Cartesian Coordinates({np.ndarray.__str__(self)} {self.units.name})" + + def __new__(cls, input_array, units="Å") -> "CartesianCoordinates": + """New instance of these coordinates""" + + # if it has units cast into current units + if isinstance(input_array, ValueArray): + input_array = ValueArray.to(input_array, units=units) + + return super().__new__( + cls, np.array(input_array).flatten(), units=units + ) + + def __array_finalize__(self, obj) -> None: + """See https://numpy.org/doc/stable/user/basics.subclassing.html""" + return None if obj is None else super().__array_finalize__(obj) + + def _str_is_valid_unit(self, string) -> bool: + """Is a string a valid unit for these coordinates e.g. nm""" + return any(string in unit.aliases for unit in self.implemented_units) + + def _update_g_from_cart_g(self, arr: Optional["Gradient"]) -> None: + """ + Updates the gradient from a calculated Cartesian gradient, which for + Cartesian coordinates there is nothing to be done for. + + ----------------------------------------------------------------------- + Arguments: + arr: Gradient array + """ + self._g = None if arr is None else np.array(arr).flatten() + + def _update_h_from_cart_h(self, arr: Optional["Hessian"]) -> None: + """ + Update the Hessian from a Cartesian Hessian matrix with shape + 3N x 3N for a species with N atoms. + + + ----------------------------------------------------------------------- + Arguments: + arr: Hessian matrix + """ + assert self.h_or_h_inv_has_correct_shape(arr) + self._h = None if arr is None else np.array(arr) + + @property + def n_constraints(self) -> int: + return 0 + + @property + def n_satisfied_constraints(self) -> int: + return 0 + + @property + def active_indexes(self) -> List[int]: + return list(range(len(self))) + + @property + def inactive_indexes(self) -> List[int]: + return [] + + def iadd(self, value: np.ndarray) -> OptCoordinates: + return np.ndarray.__iadd__(self, value) + + def to(self, value: str) -> OptCoordinates: + """ + Transform between cartesian and internal coordinates e.g. delocalised + internal coordinates or other units + + ----------------------------------------------------------------------- + Arguments: + value (str): Intended conversion + + Returns: + (autode.opt.coordinates.OptCoordinates): Transformed coordinates + + Raises: + (ValueError): If the conversion cannot be performed + """ + logger.info(f"Transforming Cartesian coordinates to {value}") + + if value.lower() in ("cart", "cartesian", "cartesiancoordinates"): + return self + + elif value.lower() in ("dic", "delocalised internal coordinates"): + return DIC.from_cartesian(self) + + # ---------- Implement other internal transformations here ----------- + + elif self._str_is_valid_unit(value): + return CartesianCoordinates( + ValueArray.to(self, units=value), units=value + ) + else: + raise ValueError( + f"Cannot convert Cartesian coordinates to {value}" + ) + + @property + def cart_proj_g(self) -> Optional[np.ndarray]: + return self.g + + @property + def expected_number_of_dof(self) -> int: + """Expected number of degrees of freedom for the system""" + n_atoms = len(self.flatten()) // 3 + return 3 * n_atoms - 6 diff --git a/autodE/source/autode/opt/coordinates/dic.py b/autodE/source/autode/opt/coordinates/dic.py new file mode 100644 index 0000000000000000000000000000000000000000..e0a39d9db4dfd62f8b8a276f6fc2e93ca41b6b11 --- /dev/null +++ b/autodE/source/autode/opt/coordinates/dic.py @@ -0,0 +1,576 @@ +# mypy: disable-error-code="has-type" +""" +Delocalised internal coordinate implementation from: +1. https://aip.scitation.org/doi/pdf/10.1063/1.478397 +and references cited therein. Also used is +2. https://aip.scitation.org/doi/pdf/10.1063/1.1515483 + +The notation follows the paper and is briefly +summarised below: + +| x : Cartesian coordinates +| B : Wilson B matrix +| G : 'Spectroscopic G matrix' +| q : Redundant internal coordinates +| s : Non-redundant internal coordinates +| U : Transformation matrix q -> s +""" +import numpy as np +from time import time +from typing import Optional, List, TYPE_CHECKING + +from autode.geom import proj +from autode.log import logger +from autode.opt.coordinates.internals import ( + PIC, + PrimitiveInverseDistances, + InternalCoordinates, +) +from autode.exceptions import CoordinateTransformFailed + +if TYPE_CHECKING: + from autode.opt.coordinates import CartesianCoordinates, OptCoordinates + from autode.values import Gradient + from autode.hessians import Hessian + + +MAX_BACK_TRANSFORM_ITERS = 50 + + +class DIC(InternalCoordinates): # lgtm [py/missing-equals] + """Delocalised internal coordinates (DIC)""" + + def __repr__(self): + return f"DIC(n={len(self)})" + + @staticmethod + def _calc_U(primitives: PIC, x: "CartesianCoordinates") -> np.ndarray: + r""" + Transform matrix containing the non-redundant eigenvectors of the G + matrix. + + .. math:: + + G (U R) = (U R) \begin{pmatrix} + \Lambda & 0 \\ + 0 & 0 + \end{pmatrix} + + where + + .. math:: + + G = B B^{T} + + + ----------------------------------------------------------------------- + Arguments: + primitives (autode.opt.internals.PIC): + + Returns: + (np.ndarray): U + """ + # calculate spectroscopic G matrix + B = primitives.get_B(x) + G = np.dot(B, B.T) + lambd, u = np.linalg.eigh(G) + + # Form a transform matrix from the primitive internals by removing the + # redundant subspace comprised of small eigenvalues. This forms a set + # of 3N - 6 non-redundant internals for a system of N atoms + idxs = np.where(np.abs(lambd) > 1e-10)[0] + + if len(idxs) < x.expected_number_of_dof: + raise RuntimeError( + "Failed to create a complete set of delocalised internal " + f"coordinates. {len(idxs)} < 3 N_atoms - 6. Likely due to " + f"missing primitives" + ) + + logger.info(f"Removed {len(lambd) - len(idxs)} redundant vectors") + return u[:, idxs] + + @classmethod + def from_cartesian( + cls, + x: "CartesianCoordinates", + primitives: Optional[PIC] = None, + ) -> "DIC": + """ + Convert cartesian coordinates to primitives then to delocalised + internal coordinates (DICs), of which there should be 3N-6 for a + polyatomic system with N atoms + + ----------------------------------------------------------------------- + Arguments: + x: Cartesian coordinates + + primitives: Primitive internal coordinates. If undefined then use + all pairwise inverse distances + + Returns: + (autode.opt.coordinates.DIC): Delocalised internal coordinates + """ + logger.info("Converting cartesian coordinates to DIC") + start_time = time() + + if primitives is None: + logger.info("Building DICs from all inverse distances") + primitives = PrimitiveInverseDistances.from_cartesian(x) + + q = primitives(x) + U = cls._calc_U(primitives, x) + U = _symmetry_inequivalent_u(U, q) + + dic = cls(input_array=np.matmul(U.T, q)) + + dic.U = U # Transform matrix primitives -> non-redundant + + dic.B = np.matmul(U.T, primitives.get_B(x)) + dic.B_T_inv = np.linalg.pinv(dic.B) + dic._q = q.copy() + dic._x = x.copy() + dic.primitives = primitives + + dic.e = x.e # Energy + dic.update_g_from_cart_g(x.g) # Gradient + dic.update_h_from_cart_h(x.h) # and Hessian + + logger.info(f"Transformed in ...{time() - start_time:.4f} s") + return dic + + @property + def cart_proj_g(self) -> Optional[np.ndarray]: + return self.to("cart").g + + def _update_g_from_cart_g(self, arr: Optional["Gradient"]) -> None: + """ + Updates the gradient from a calculated Cartesian gradient + + ----------------------------------------------------------------------- + Arguments: + arr: Cartesian gradient array + """ + if arr is None: + self._x.g, self._g = None, None + + else: + self._x.g = arr.flatten() + self._g = np.matmul(self.B_T_inv.T, self._x.g) + + return None + + def _update_h_from_cart_h(self, arr: Optional["Hessian"]) -> None: + """ + Update the DIC Hessian matrix from a Cartesian one + + ----------------------------------------------------------------------- + Arguments: + arr: Cartesian Hessian matrix + """ + if arr is None: + self._x.h, self._h = None, None + + else: + self._x.h = arr + + # NOTE: This is not the full transformation as noted in + # 10.1063/1.471864 only an approximate Hessian is required(?) + hess = np.linalg.multi_dot((self.B_T_inv.T, arr, self.B_T_inv)) + assert self.h_or_h_inv_has_correct_shape(hess) + self._h = hess + + return None + + def to(self, value: str) -> "OptCoordinates": + """ + Convert these DICs to another type of coordinate + + ----------------------------------------------------------------------- + Arguments: + value (str): e.g. "Cartesian" + + Returns: + (autode.opt.coordinates.OptCoordinates): Coordinates + """ + + if value.lower() in ("x", "cart", "cartesian"): + return self._x + + raise ValueError(f"Unknown conversion to {value}") + + def iadd(self, value: np.ndarray) -> "OptCoordinates": + """ + Set some new internal coordinates and update the Cartesian coordinates + + .. math:: + + x^(k+1) = x(k) + ({B^T})^{-1}(k)[s_{new} - s(k)] + + for an iteration k. + + ---------------------------------------------------------------------- + Keyword Arguments: + + value: Difference between the current and new DICs. Must be + the same shape as self into self.shape. + Raises: + (RuntimeError): If the transformation diverges + """ + start_time = time() + s_new = np.array(self, copy=True) + value + + # Initialise + s_k, x_k = np.array(self, copy=True), self.to("cartesian").copy() + q_init = self._q + x_1 = self.to("cartesian") + np.matmul(self.B_T_inv, value) + + success = False + rms_s = np.inf + # NOTE: J. Comput. Chem., 2013, 34, 1842 suggests if step size + # is larger than 0.5 bohr (= 0.2 Å), internal step can be halved + # for easier convergence (i.e. damp = 1/2) + if np.linalg.norm(value) > 0.2: + damp = 0.5 + else: + damp = 1.0 + + # hybrid SIBT/IBT algorithm + for i in range(1, MAX_BACK_TRANSFORM_ITERS + 1): + try: + x_k = x_k + np.matmul(self.B_T_inv, damp * (s_new - s_k)) + + # Rebuild the DIC from back-transformed Cartesians + q_k = self.primitives.close_to(x_k, q_init) + s_k = np.matmul(self.U.T, q_k) + + # Rebuild the B matrix every 10 steps + if i % 10 == 0: + self.B = np.matmul(self.U.T, self.primitives.get_B(x_k)) + self.B_T_inv = np.linalg.pinv(self.B) + + rms_s_old = rms_s + rms_s = np.sqrt(np.mean(np.square(s_k - s_new))) + + # almost converged, turn off damping + if rms_s < 1e-6: + damp = 1.0 + # RMS going down, reduce damping + elif rms_s < rms_s_old and i > 1: + damp = min(1.2 * damp, 1.0) + # RMS going up, increase damping + elif rms_s > rms_s_old: + damp = max(0.7 * damp, 0.1) + + # for ill-conditioned primitives, there might be math error + except ArithmeticError: + break + + if rms_s < 1e-10: + success = True + break + + if success: + logger.info( + f"DIC transformation converged in {i} cycle(s) " + f"in {time() - start_time:.4f} s" + ) + else: + logger.warning( + f"Failed to transform in {i} cycles. " + + f"Final RMS(s) = {rms_s:.8f}" + ) + x_k = x_1 + if not self.allow_unconverged_back_transform: + raise CoordinateTransformFailed( + "DIC->Cart iterative back-transform did not converge" + ) + + q_k = self.primitives.close_to(x_k, q_init) + s_k = np.matmul(self.U.T, q_k) + self.B = np.matmul(self.U.T, self.primitives.get_B(x_k)) + self.B_T_inv = np.linalg.pinv(self.B) + + self[:] = s_k + self._q = q_k + self._x = x_k + + return self + + @property + def active_indexes(self) -> List[int]: + """A list of indexes for the active modes in this coordinate set""" + return list(range(len(self))) + + @property + def inactive_indexes(self) -> List[int]: + """A list of indexes for the non-active modes in this coordinate set""" + return [] + + +class DICWithConstraints(DIC): + r""" + Delocalised internal coordinates (DIC) with constraints. Uses Lagrangian + multipliers to enforce the constraints with: + + ..math:: + + L(X, λ) = E(s) + \sum_{i=1}^m \lambda_i C_i(X) + + where s are internal coordinates, and C the constraint functions. The + optimisation space then is the n non-constrained internal coordinates and + the m Lagrangian multipliers (\lambda_i). + """ + + def __new__(cls, input_array) -> "InternalCoordinates": + """New instance of these internal coordinates""" + + arr = super().__new__(cls, input_array) + + arr._lambda = np.array([]) # Additional lagrangian multipliers + return arr + + def __array_finalize__(self, obj: "OptCoordinates") -> None: + """See https://numpy.org/doc/stable/user/basics.subclassing.html""" + super().__array_finalize__(obj) + self._lambda = getattr(obj, "_lambda", None) + return + + @classmethod + def from_cartesian( + cls, + x: "CartesianCoordinates", + primitives: Optional[PIC] = None, + ) -> "DICWithConstraints": + """ + Generate delocalised internal coordinates with constraints + with the Lagrangian multipliers initialised as zeroes + + Args: + x: Cartesian coordinates + + primitives: Primitive internal coordinates. If undefined then use + all pairwise inverse distances + + Returns: + (DICWithConstraints): DIC with constraints + """ + dic = super().from_cartesian(x=x, primitives=primitives) + dic._lambda = np.zeros(shape=(dic.n_constraints,)) + return dic + + @property + def raw(self) -> np.ndarray: + """Raw numpy array of these coordinates including the multipliers""" + assert self._lambda is not None, "Must have λ defined" + return np.array(self.tolist() + self._lambda.tolist(), copy=True) + + @staticmethod + def _calc_U(primitives: PIC, x: "CartesianCoordinates") -> np.ndarray: + """Eigenvectors of the G matrix""" + + u = DIC._calc_U(primitives, x) + const_prim_idxs = [ + i + for i, primitive in enumerate(primitives) + if primitive.is_constrained + ] + + logger.info( + f"Projecting {len(const_prim_idxs)} constrained primitives" + ) + return _schmidt_orthogonalise(u, *const_prim_idxs) + + @property + def inactive_indexes(self) -> List[int]: + """ + Generate a list of mode indexes that are inactive in the optimisation + space. This *requires* the m constrained modes being at the end of the + coordinate set. It also includes the lagrange multipliers + """ + + n, m = len(self), self.n_constraints + x = self.to("cartesian") + idxs = [ + i + for i, p in enumerate(self.constrained_primitives) + if p.is_satisfied(x) + ] + + return [n - m + i for i in idxs] + [n + i for i in idxs] + + @property + def active_indexes(self) -> List[int]: + """Generate a list of indexes for the active modes in this coordinate + set""" + n, m = len(self), self.n_constraints # n dic + m lagrange multipliers + + return [i for i in range(n + m) if i not in self.inactive_indexes] + + def iadd(self, value: np.ndarray) -> "OptCoordinates": + """ + Add a step in internal coordinates (along with Lagrange multipliers) + to this set of coordinates, and update the Cartesian coordinates + + Args: + value: Difference between current and new DICs, and the multipliers + """ + assert len(value) == len(self) + self.n_constraints + # separate the coordinates and the lagrange multipliers + if self.n_constraints > 0: + delta_lambda = value[-self.n_constraints :] + self._lambda += delta_lambda + delta_s = value[: -self.n_constraints] + else: + delta_s = value + + return super().iadd(delta_s) + + @property + def cart_proj_g(self) -> Optional[np.ndarray]: + """Obtain Cartesian gradient with constraints projected out""" + if self.g is None: + return None + # constrained gradient with inactive terms set to zero + g_s = self.g + g_s[self.inactive_indexes] = 0.0 + g_s = g_s[: len(self)] + # back to Cartesian + g_x = np.matmul(self.B.T, g_s) + assert len(g_x) == len(self.to("cart")) + return g_x + + @property + def g(self): + """ + Gradient of the energy, contains the Lagrangian dL/d_λi terms where + λi is the i-th lagrangian multiplier. + """ + if self._g is None: + return None + + n, m = len(self), self.n_constraints + arr = np.zeros(shape=(n + m,)) + arr[:n] = self._g + + # constrained gradient terms + for i in range(m): + arr[n - m + i] -= self._lambda[i] * 1 # λ dC_i/ds_i + + # final dL/dλ_i + c = self.constrained_primitives + for i in range(m): + arr[n + i] = -c[i].delta(self._x) # C_i(x) = Z - Z_ideal + + return arr + + @g.setter + def g(self, value): + """Setting g is not allowed with constraints""" + raise RuntimeError("Cannot set gradient with constraints enabled") + + @property + def h(self): + """ + The Hessian matrix, containing Lagrangian constraint terms + + Returns: + (np.ndarray): + """ + if self._h is None: + return None + + n, m = len(self), self.n_constraints + arr = np.zeros(shape=(n + m, n + m)) + + # Upper left corner is d^2L/ds_i ds_j + arr[:n, :n] = self._h + + # and the d^2L/ds_i dλ_i = -dC_i/ds_i = -1 + # d^2L/dλ_i dλ_j = 0 + for i in range(m): + arr[n + i, :] = arr[:, n + i] = 0.0 + + for i in range(m): + arr[n - m + i, n + i] = arr[n + i, n - m + i] = -1.0 + + return arr + + @h.setter + def h(self, value): + raise RuntimeError("Cannot set hessian when constraints are enabled") + + def update_lagrange_multipliers(self, arr: np.ndarray) -> None: + """Update the lagrange multipliers by adding a set of values""" + assert self._lambda is not None, "Must have λ defined" + + if arr.shape != self._lambda.shape: + raise ValueError( + "Cannot set lagrange multipliers. Incorrect shape" + ) + + self._lambda[:] = np.asarray(self._lambda) + np.asarray(arr) + return None + + +def _schmidt_orthogonalise(arr: np.ndarray, *indexes: int) -> np.ndarray: + """ + Perform Schmidt orthogonalization to generate orthogonal vectors + that include a number of unit vectors, the non-zero components of which + are defined by indexes. This generates a transform matrix U which will + provide pure primitive coordinates, which can then be constrained simply + """ + logger.info( + f"Schmidt-orthogonalizing. Using {indexes} as orthonormal vectors" + ) + + u = np.zeros_like(arr) + _, n = arr.shape + m = len(indexes) + + # Set the unit vectors as the first m columns + for i, index in enumerate(indexes): + u[index, i] = 1.0 + + # and the remaining n-m columns as the orthogonalised values + for i in range(m, n): + u_i = arr[:, i] + for j in range(0, i): + u_i -= proj(u[:, j], arr[:, i]) + + u_i /= np.linalg.norm(u_i) + + u[:, i] = u_i.copy() + + # Arbitrarily place the defined unit vectors at the end + permutation = list(range(m, n)) + list(range(m)) + return u[:, permutation] + + +def _symmetry_inequivalent_u(u, q) -> np.ndarray: + """Remove symmetry equivalent vectors from the U matrix""" + + # The non-redundant space can be further pruned by considering symmetry + idxs: List[int] = [] + s = np.matmul(u.T, q) + + for i, s_i in enumerate(s): + is_unique = all(not np.isclose(s_i, s[j], atol=1e-20) for j in idxs) + + if is_unique or _is_pure_primitive(u[:, i]): + idxs.append(i) + + logger.info(f"Removing {len(s) - len(idxs)} symmetry equiv. DICs") + return u[:, idxs] + + +def _is_pure_primitive(v: np.ndarray) -> bool: + """ + Is this vector a pure primitive? Defined by all but one of the coefficients + being zero + """ + + def n_values_close_to(value): + return sum(np.isclose(v_i, value, atol=1e-10) for v_i in v) + + return n_values_close_to(0.0) == len(v) - 1 and n_values_close_to(1.0) == 1 diff --git a/autodE/source/autode/opt/coordinates/dimer.py b/autodE/source/autode/opt/coordinates/dimer.py new file mode 100644 index 0000000000000000000000000000000000000000..b5a9c8c6c5a149622270a4d7c391b768f46eb786 --- /dev/null +++ b/autodE/source/autode/opt/coordinates/dimer.py @@ -0,0 +1,289 @@ +""" +Coordinates for dimer optimisations. Notation follows: +[1] https://aip.scitation.org/doi/pdf/10.1063/1.2815812 +""" +import numpy as np + +from enum import IntEnum, unique +from typing import Union, Sequence, Optional, TYPE_CHECKING + +from autode.opt.coordinates.base import OptCoordinates +from autode.log import logger +from autode.values import Angle, MWDistance +from autode.units import ang_amu_half + +if TYPE_CHECKING: + from autode.units import Unit + from autode.species.species import Species + from autode.hessians import Hessian + from autode.values import Gradient + + +@unique +class DimerPoint(IntEnum): + """Points in the coordinate space forming the dimer""" + + midpoint = 0 + left = 1 + right = 2 + + +class DimerCoordinates(OptCoordinates): + """Mass weighted Cartesian coordinates for two points in space forming + a dimer, such that the midpoint is close to a first order saddle point""" + + implemented_units = [ang_amu_half] + + def __new__( + cls, + input_array: Union[Sequence, np.ndarray], + units: Union[str, "Unit"] = "Å amu^1/2", + ) -> "OptCoordinates": + """New instance of these coordinates""" + + arr = super().__new__(cls, np.array(input_array), units) + + if arr.ndim != 2 or arr.shape[0] != 3: + raise ValueError( + "Dimer coordinates must be initialised from a " + "3x3N array for a system with N atoms" + ) + + arr._e = None # Energy + arr._dist = MWDistance(0.0, "Å amu^1/2") # Translation distance + arr._phi = Angle(0.0, "radians") # Rotation amount + + """ + Compared to standard Cartesian coordinates these arrays have and + additional dimension for the two end points of the dimer. + """ + arr._g = None # Gradient: {dE/dX_0, dE/dX_1, dE/dx_2} + arr._h = None # Hessian: {d2E/dXdY_0, d2E/dXdY_1, d2E/dXdY_2} + + arr.masses = None # Atomic masses + + return arr + + def __array_finalize__(self, obj: "OptCoordinates") -> None: + """See https://numpy.org/doc/stable/user/basics.subclassing.html""" + + for attr in ("units", "_e", "_g", "_h", "_dist", "_phi", "masses"): + self.__dict__[attr] = getattr(obj, attr, None) + + return None + + @classmethod + def from_species( + cls, + species1: "Species", + species2: "Species", + ) -> "DimerCoordinates": + """ + Initialise a set of DimerCoordinates from two species, i.e. those + either side of the saddle point. + """ + if not species1.has_identical_composition_as(species2): + raise ValueError( + "Cannot form a set of dimer coordinates from two " + "species with a different number of atoms" + ) + + coords = cls( + np.stack( + ( + np.empty(3 * species1.n_atoms), + np.array(species1.coordinates).flatten(), + np.array(species2.coordinates).flatten(), + ), + axis=0, + ) + ) + + # Mass weight the coordinates by m^1/2 for each atom + coords.masses = np.repeat( + np.array(species1.atomic_masses, dtype=float), + repeats=3, + axis=np.newaxis, + ) + + coords *= np.sqrt(coords.masses) + + return coords + + def _update_g_from_cart_g(self, arr: Optional["Gradient"]) -> None: + raise NotImplementedError( + "Cannot update the gradient - indeterminate " "point in the dimer" + ) + + def _update_h_from_cart_h(self, arr: Optional["Hessian"]) -> None: + logger.warning("Dimer does not require Hessians - skipping") + return None + + def __repr__(self) -> str: + return ( + f"Dimer Coordinates({np.ndarray.__str__(self)} {self.units.name})" + ) + + def __eq__(self, other): + """Coordinates can never be identical...""" + return False + + def to(self, *args, **kwargs) -> "OptCoordinates": + raise NotImplementedError( + "Cannot convert dimer coordinates to other " "types" + ) + + def iadd(self, value: np.ndarray) -> "OptCoordinates": + return np.ndarray.__iadd__(self, value) + + def x_at( + self, point: DimerPoint, mass_weighted: bool = True + ) -> np.ndarray: + """Coordinates at a point in the dimer""" + + if mass_weighted is False and self.masses is None: + raise RuntimeError( + "Cannot un-mass weight the coordinates, " + "coordinates had no masses set" + ) + + if point == DimerPoint.midpoint: + x = self.x0 + + else: + x = np.array(self)[int(point), :] + + return x if mass_weighted else x / np.sqrt(self.masses) + + @property + def x0(self) -> np.ndarray: + """Midpoint of the dimer""" + return (self.x1 + self.x2) / 2.0 + + @property + def x1(self) -> np.ndarray: + """Coordinates on the 'left' side of the dimer""" + return np.array(self)[int(DimerPoint.left), :] + + @x1.setter + def x1(self, arr: np.ndarray): + self[int(DimerPoint.left), :] = arr[:] + + @property + def x2(self) -> np.ndarray: + """Coordinates on the 'right' side of the dimer""" + return np.array(self)[int(DimerPoint.right), :] + + @x2.setter + def x2(self, arr: np.ndarray): + self[int(DimerPoint.right), :] = arr[:] + + def g_at(self, point: DimerPoint) -> np.ndarray: + if self._g is None: + raise RuntimeError(f"Cannot get the gradient at {point}") + + return self._g[int(point), :] + + def set_g_at( + self, point: DimerPoint, arr: np.ndarray, mass_weighted: bool = True + ): + """Set the gradient vector at a particular point""" + + if self._g is None: + self._g = np.zeros_like(self) + + if not mass_weighted: + if self.masses is None: + raise RuntimeError( + "Cannot set the mass-weighted gradient " "without masses" + ) + + arr *= np.sqrt(self.masses) + + self._g[int(point), :] = arr + + @property + def g0(self) -> np.ndarray: + """Gradient at the midpoint of the dimer""" + return self.g_at(DimerPoint.midpoint) + + @g0.setter + def g0(self, arr: np.ndarray): + self.set_g_at(DimerPoint.midpoint, arr) + + @property + def g1(self) -> np.ndarray: + """Gradient on the 'left' side of the dimer""" + return self.g_at(DimerPoint.left) + + @property + def g2(self) -> np.ndarray: + """Gradient on the 'right' side of the dimer""" + return self.g_at(DimerPoint.right) + + @property + def tau(self) -> np.ndarray: + """Direction between the two ends of the dimer (τ)""" + return (self.x1 - self.x2) / 2.0 + + @property + def tau_hat(self) -> np.ndarray: + """Normalised direction between the two ends of the dimer""" + tau = self.tau + return tau / np.linalg.norm(tau) + + @property + def f_r(self) -> np.ndarray: + """Rotational force F_R. eqn. 3 in ref. [1]""" + tau_hat = self.tau_hat + x = 2.0 * (self.g1 - self.g0) + + return -x + np.dot(x, tau_hat) * tau_hat + + @property + def f_t(self) -> np.ndarray: + """Translational force F_T, eqn. 2 in ref. [1]""" + g0, tau_hat = self.g0, self.tau_hat + + return -g0 + 2.0 * np.dot(g0, tau_hat) * tau_hat + + @property + def delta(self) -> float: + """Distance between the dimer point, Δ""" + return MWDistance( + np.linalg.norm(self.x1 - self.x2) / 2.0, units=self.units + ) + + @property + def phi(self) -> Angle: + """Angle that the dimer was rotated by from its last position""" + return self._phi + + @phi.setter + def phi(self, value: Angle): + if not isinstance(value, Angle): + raise ValueError("phi must be an autode.values.Angle instance") + + self._phi = value + + @property + def dist(self) -> MWDistance: + """Distance that the dimer was translated by from its last position""" + return self._dist + + @dist.setter + def dist(self, value: MWDistance): + if not isinstance(value, MWDistance): + raise ValueError("dist must be an autode.values.Distance instance") + + self._dist = value + + @property + def did_rotation(self): + """Rotated this iteration?""" + return abs(self._phi) > 1e-10 + + @property + def did_translation(self): + """Translated this iteration?""" + return abs(self.dist) > 1e-10 diff --git a/autodE/source/autode/opt/coordinates/internals.py b/autodE/source/autode/opt/coordinates/internals.py new file mode 100644 index 0000000000000000000000000000000000000000..d4b608bffc04d83ceddece12888d747cbe2aaabf --- /dev/null +++ b/autodE/source/autode/opt/coordinates/internals.py @@ -0,0 +1,608 @@ +""" +Internal coordinates. Notation follows: + + +x : Cartesian coordinates +B : Wilson B matrix +q : Primitive internal coordinates +G : Spectroscopic G matrix + +Set-up of redundant primitives is based on J. Chem. Phys., 117, 2002, 9160 +""" +import numpy as np +import itertools +from typing import Any, Optional, Type, List, TYPE_CHECKING +from abc import ABC, abstractmethod +from autode.values import Angle, Distance +from autode.opt.coordinates.base import OptCoordinates +from autode.opt.coordinates.primitives import ( + PrimitiveInverseDistance, + Primitive, + PrimitiveDistance, + ConstrainedPrimitiveDistance, + PrimitiveBondAngle, + PrimitiveDummyLinearAngle, + PrimitiveLinearAngle, + PrimitiveDihedralAngle, + PrimitiveImproperDihedral, + LinearBendType, +) + +if TYPE_CHECKING: + from autode.species import Species + from autode.opt.coordinates.cartesian import CartesianCoordinates + from autode.opt.coordinates.primitives import ( + ConstrainedPrimitive, + _DistanceFunction, + ) + + +# Angle threshold for linearity (should be in radians) +_lin_thresh = Angle(170, "deg").to("rad") + + +class InternalCoordinates(OptCoordinates, ABC): # lgtm [py/missing-equals] + def __new__(cls, input_array) -> "InternalCoordinates": + """New instance of these internal coordinates""" + + arr = super().__new__(cls, input_array, units="Å") + + arr._x = None + arr._q = None + arr.primitives = None + + for attr in ("_x", "primitives", "_q"): + setattr(arr, attr, getattr(input_array, attr, None)) + + return arr + + def __array_finalize__(self, obj: "OptCoordinates") -> None: + """See https://numpy.org/doc/stable/user/basics.subclassing.html""" + OptCoordinates.__array_finalize__(self, obj) + + for attr in ("_x", "primitives", "_q"): + setattr(self, attr, getattr(obj, attr, None)) + + return + + @property + def n_constraints(self) -> int: + """Number of constraints in these coordinates""" + return self.primitives.n_constrained + + @property + def constrained_primitives(self) -> List["ConstrainedPrimitive"]: + return [p for p in self.primitives if p.is_constrained] + + @property + def n_satisfied_constraints(self) -> int: + """Number of constraints that are satisfied in these coordinates""" + x = self.to("cartesian") + return sum(p.is_satisfied(x) for p in self.constrained_primitives) + + +class PIC(list, ABC): + """Primitive internal coordinates""" + + def __init__(self, *args: Any): + """ + List of primitive internal coordinates with a Wilson B matrix. + If there are no arguments then all possible primitive coordinates + will be generated + """ + super().__init__(args) + + if not self._are_all_primitive_coordinates(args): + raise ValueError( + "Cannot construct primitive internal coordinates " + f"from {args}. Must be primitive internals" + ) + + def add(self, item: Primitive) -> None: + """Add a primitive to this set of primitive coordinates""" + assert isinstance(item, Primitive), "Must be a primitive" + # prevent duplication of primitives + if item not in self: + super().append(item) + + def append(self, item: Primitive) -> None: + """Appending directly is not allowed, use add() instead""" + raise NotImplementedError( + "Please use PIC.add() to add new primitives to the set" + ) + + @classmethod + def from_cartesian( + cls, + x: "CartesianCoordinates", + ) -> "PIC": + """Construct a complete set of primitive internal coordinates from + a set of Cartesian coordinates""" + + pic = cls() + pic._populate_all(x=x) + + return pic + + def __call__(self, x: np.ndarray) -> np.ndarray: + """Populate Primitive-s used in the construction of set""" + + q = self._calc_q(x) + + return q + + def close_to(self, x: np.ndarray, other: np.ndarray) -> np.ndarray: + """ + Calculate a set of primitive internal coordinates (PIC) that are + 'close to' another set. This means that the restriction on dihedral + angles being in the range (-π, π] is relaxed in favour of the smallest + ∆q possible (where q is a value of a primitive coordinate). + """ + assert len(self) == len(other) and isinstance(other, np.ndarray) + + q = self._calc_q(x) + + for i, primitive in enumerate(self): + if isinstance(primitive, PrimitiveDihedralAngle): + dq = q[i] - other[i] + + if np.abs(dq) > np.pi: # Ensure |dq| < π + q[i] -= np.sign(dq) * 2 * np.pi + + return q + + def __eq__(self, other: Any): + """Comparison of two PIC sets""" + + is_equal = ( + isinstance(other, PIC) + and len(other) == len(self) + and all(p0 == p1 for p0, p1 in zip(self, other)) + ) + + return is_equal + + def _calc_q(self, x: np.ndarray) -> np.ndarray: + """Calculate the value of the internals""" + + if len(self) == 0: + self._populate_all(x) + + return np.array([q(x) for q in self]) + + @abstractmethod + def _populate_all(self, x: np.ndarray) -> None: + """Populate primitives from an array of cartesian coordinates""" + + def get_B(self, x: np.ndarray) -> np.ndarray: + """Calculate the Wilson B matrix""" + + if len(self) == 0: + raise ValueError( + "Cannot calculate the Wilson B matrix, no " + "primitive internal coordinates" + ) + + cart_coords = x.ravel() + + B = np.zeros(shape=(len(self), len(cart_coords))) + + for i, primitive in enumerate(self): + B[i] = primitive.derivative(x=cart_coords) + + return B + + @staticmethod + def _are_all_primitive_coordinates(args: tuple) -> bool: + return all(isinstance(arg, Primitive) for arg in args) + + @property + def n_constrained(self) -> int: + """Number of constrained primitive internal coordinates""" + return sum(p.is_constrained for p in self) + + +class _FunctionOfDistances(PIC): + @property + @abstractmethod + def _primitive_type(self) -> Type["_DistanceFunction"]: + """Type of primitive coordinate defining f(r_ij)""" + + def _populate_all(self, x: np.ndarray): + n_atoms = len(x.flatten()) // 3 + + # Add all the unique inverse distances (i < j) + for i in range(n_atoms): + for j in range(i + 1, n_atoms): + self.add(self._primitive_type(i, j)) + + return None + + +class PrimitiveInverseDistances(_FunctionOfDistances): + """1 / r_ij for all unique pairs i,j. Will be redundant""" + + @property + def _primitive_type(self): + return PrimitiveInverseDistance + + +class PrimitiveDistances(_FunctionOfDistances): + """r_ij for all unique pairs i,j. Will be redundant""" + + @property + def _primitive_type(self): + return PrimitiveDistance + + +class AnyPIC(PIC): + def _populate_all(self, x: np.ndarray) -> None: + raise RuntimeError("Cannot populate all on an AnyPIC instance") + + @classmethod + def from_species(cls, mol: "Species") -> "AnyPIC": + """ + Build a set of primitives from the species, using the graph as + a starting point for the connectivity of the species. Also joins + any disjoint parts of the graph, and adds hydrogen bonds to + ensure that the primitives are redundant. + + Args: + mol: The species object + + Returns: + (AnyPIC): The set of primitive internals + """ + pic = cls() + # take a copy of mol as mol.graph might be changed + mol = mol.copy() + _connect_graph_for_species(mol) + pic._add_bonds_from_species(mol) + pic._add_angles_from_species(mol) + pic._add_dihedrals_from_species(mol) + pic._add_chain_dihedrals_from_species(mol) + return pic + + def _add_bonds_from_species( + self, + mol: "Species", + ): + """ + Add bonds to the current set of primitives, from the + connectivity graph of the species + + Args: + mol: The species object + """ + assert mol.graph is not None + + n = 0 + for i, j in sorted(mol.graph.edges): + if ( + mol.constraints.distance is not None + and (i, j) in mol.constraints.distance + ): + r = mol.constraints.distance[(i, j)] + self.add(ConstrainedPrimitiveDistance(i, j, r)) + n += 1 + else: + self.add(PrimitiveDistance(i, j)) + assert n == mol.constraints.n_distance + + return None + + @staticmethod + def _get_ref_for_linear_angle( + mol, + a, + b, + c, + bonded: bool, + dist_thresh=Distance(4, "ang"), + ) -> Optional[int]: + """ + Get a reference atom for describing a linear angle, which + must not itself be linear to the atoms in the angle in + any combination. The linear angle is a--b--c here. + + Args: + mol: + a: + b: + c: + bonded: Whether to look for only atoms bonded to the central + atom (b) for reference + dist_thresh: The distance threshold to check for atoms if + bonded = False + + Returns: + (int|None): The index of the ref. atom if found, else None + """ + + # only check bonded atoms if requested + if bonded: + near_atoms = list(mol.graph.neighbors(b)) + near_atoms.remove(a) + near_atoms.remove(c) + + # otherwise get all atoms in 4 A radius except a, b, c + else: + near_atoms = [ + idx + for idx in range(mol.n_atoms) + if mol.distance(b, idx) < dist_thresh and idx not in (a, b, c) + ] + + # get atoms closest to perpendicular + deviations_from_90 = {} + for atom in near_atoms: + i_b_a = mol.angle(atom, b, a) + if i_b_a > _lin_thresh or i_b_a < (np.pi - _lin_thresh): + continue + i_b_c = mol.angle(atom, b, c) + if i_b_c > _lin_thresh or i_b_c < (np.pi - _lin_thresh): + continue + deviation_a = abs(i_b_a - np.pi / 2) + deviation_b = abs(i_b_c - np.pi / 2) + avg_dev = (deviation_a + deviation_b) / 2 + deviations_from_90[atom] = avg_dev + + if len(deviations_from_90) == 0: + return None + + return min(deviations_from_90, key=deviations_from_90.get) # type: ignore + + def _add_angles_from_species( + self, + mol: "Species", + ) -> None: + """ + Modify the set of primitives in-place by adding angles, from the + connectivity graph supplied + + Args: + mol (Species): The species object + """ + assert mol.graph is not None + + for o in range(mol.n_atoms): + for n, m in itertools.combinations(mol.graph.neighbors(o), r=2): + if mol.angle(m, o, n) < _lin_thresh: + self.add(PrimitiveBondAngle(m=m, o=o, n=n)) + else: + # If central atom is connected to another atom, then the + # linear angle is skipped and instead an out-of-plane + # (improper dihedral) coordinate is used + r = self._get_ref_for_linear_angle( + mol, m, o, n, bonded=True + ) + if r is not None: + self.add(PrimitiveImproperDihedral(m, r, o, n)) + continue + + # Otherwise, we use a nearby (< 4.0 A) reference atom to + # define two orthogonal linear bends + r = self._get_ref_for_linear_angle( + mol, m, o, n, bonded=False + ) + if r is not None: + self.add( + PrimitiveLinearAngle( + m, o, n, r, LinearBendType.BEND + ) + ) + self.add( + PrimitiveLinearAngle( + m, o, n, r, LinearBendType.COMPLEMENT + ) + ) + + # For completely linear molecules (CO2), there will be no such + # reference atoms, so use dummy atoms instead + else: + self.add( + PrimitiveDummyLinearAngle( + m, o, n, LinearBendType.BEND + ) + ) + self.add( + PrimitiveDummyLinearAngle( + m, o, n, LinearBendType.COMPLEMENT + ) + ) + + return None + + def _add_dihedrals_from_species( + self, + mol: "Species", + ) -> None: + """ + Modify the set of primitives in-place by adding dihedrals (torsions), + from the connectivity graph supplied + + Args: + mol (Species): The species + """ + # no dihedrals possible with less than 4 atoms + if mol.n_atoms < 4: + return + + assert mol.graph is not None + + for o, p in list(mol.graph.edges): + for m in mol.graph.neighbors(o): + if m == p: + continue + + for n in mol.graph.neighbors(p): + if n == o: + continue + + # avoid triangle rings like cyclopropane + if n == m: + continue + + if _is_dihedral_well_defined(mol, m, o, p, n): + self.add(PrimitiveDihedralAngle(m, o, p, n)) + + return None + + @staticmethod + def _get_linear_chains(mol: "Species") -> List[List[int]]: + """ + Obtain a list of all the continuous chains of linear atoms + present in the species i.e. A--B--C--D--E... + + Args: + mol: The species + + Returns: + (list[list[int]): A list of lists, each containing indices + of the atoms of linear chains in order + """ + assert mol.graph is not None + + def extend_chain(chain: List[int]): + """Extend a chain in-place""" + for idx in range(mol.n_atoms): + if idx in chain: + continue + + if mol.angle(chain[1], chain[0], idx) > _lin_thresh: + chain.insert(0, idx) + continue + + if mol.angle(chain[-2], chain[-1], idx) > _lin_thresh: + chain.append(idx) + continue + return None + + linear_chains: List[list] = [] + for b in range(mol.n_atoms): + for a, c in itertools.combinations(mol.graph.neighbors(b), r=2): + if any( + a in chain and b in chain and c in chain + for chain in linear_chains + ): + continue + if mol.angle(a, b, c) > _lin_thresh: + chain = [a, b, c] + extend_chain(chain) + linear_chains.append(chain) + + return linear_chains + + def _add_chain_dihedrals_from_species(self, mol: "Species"): + """ + Add extra dihedrals for chain molecules like allene, which + are required to cover all degrees of freedom + + Args: + mol: + + Returns: + + """ + assert mol.graph is not None + linear_chains = self._get_linear_chains(mol) + + for chain in linear_chains: + o, p = chain[0], chain[-1] + for m in mol.graph.neighbors(o): + if m == p: + continue + + if m in chain: + continue + + for n in mol.graph.neighbors(p): + if n == o: + continue + + if n == m: + continue + + if n in chain: + continue + + if _is_dihedral_well_defined(mol, m, o, p, n): + self.add(PrimitiveDihedralAngle(m, o, p, n)) + + return None + + +def _connect_graph_for_species(mol: "Species") -> None: + """ + Creates a fully connected graph from the graph of a species, by + (1) joining disconnected fragments by their shortest distance, + (2) connecting constrained bonds, (3) joining hydrogen bonds, + if present. The molecular graph is modified in-place. + + Args: + mol: A species (must have atoms and graph) + """ + assert mol.graph is not None, "Species must have graph!" + + # join hydrogen bonds + h_bond_x = ["N", "O", "F", "P", "S", "Cl"] + for i, j in itertools.combinations(range(mol.n_atoms), r=2): + if ( + mol.atoms[i].label in h_bond_x + and mol.atoms[j].label == "H" + or mol.atoms[j].label in h_bond_x + and mol.atoms[i].label == "H" + ): + vdw_sum = mol.atoms[i].vdw_radius + mol.atoms[j].vdw_radius + if mol.distance(i, j) < 0.9 * vdw_sum: + if not mol.graph.has_edge(i, j): + mol.graph.add_edge(i, j, pi=False, active=False) + + # join disconnected graph components + if not mol.graph.is_connected: + components = mol.graph.connected_components() + for comp_i, comp_j in itertools.combinations(components, r=2): + min_dist = float("inf") + min_pair = (-1, -1) + for i, j in itertools.product(list(comp_i), list(comp_j)): + if mol.distance(i, j) < min_dist: + min_dist = mol.distance(i, j) + min_pair = (i, j) + mol.graph.add_edge(*min_pair, pi=False, active=False) + + assert mol.graph.is_connected, "Unknown error in connecting graph" + + # The constraints should be counted as bonds + if mol.constraints.distance is not None: + for i, j in mol.constraints.distance: + if not mol.graph.has_edge(i, j): + mol.graph.add_edge(i, j, pi=False, active=False) + + return None + + +def _is_dihedral_well_defined(mol, a, b, c, d) -> bool: + """ + A dihedral a--b--c--d is only well-defined when the + two constituent angles are not linear + + Args: + mol: + a: + b: + c: + d: + + Returns: + (bool): True if well-defined otherwise False + """ + zero_angle_thresh = np.pi - _lin_thresh + is_linear_1 = ( + mol.angle(a, b, c) > _lin_thresh + or mol.angle(a, b, c) < zero_angle_thresh + ) + is_linear_2 = ( + mol.angle(b, c, d) > _lin_thresh + or mol.angle(b, c, d) < zero_angle_thresh + ) + return not (is_linear_1 or is_linear_2) diff --git a/autodE/source/autode/opt/coordinates/primitives.py b/autodE/source/autode/opt/coordinates/primitives.py new file mode 100644 index 0000000000000000000000000000000000000000..530e3e7ee8f3bbf8d630813c2e0cbc96c346b636 --- /dev/null +++ b/autodE/source/autode/opt/coordinates/primitives.py @@ -0,0 +1,642 @@ +import numpy as np +import itertools +from abc import ABC, abstractmethod +from enum import Enum +from typing import Tuple, TYPE_CHECKING, List, Optional +from autode.opt.coordinates._autodiff import ( + get_differentiable_vars, + DifferentiableMath, + DifferentiableVector3D, + DerivativeOrder, + VectorHyperDual, +) + +if TYPE_CHECKING: + from autode.opt.coordinates import CartesianCoordinates + + +def _get_3d_vecs_from_atom_idxs( + *args: int, + x: "CartesianCoordinates", + deriv_order: DerivativeOrder, +) -> List[DifferentiableVector3D]: + """ + Obtain differentiable 3D vectors from the Cartesian components + of each atom, given by atomic indices in order. The symbols are + strings denoting their position in the flat Cartesian coordinate. + + Args: + *args: Integers denoting the atom positions + x: Cartesian coordinate array + deriv_order: Order of derivatives for initialising variables + + Returns: + (list[DifferentiableVector3D]): A list of differentiable variables + """ + assert all(isinstance(idx, int) and idx >= 0 for idx in args) + # get positions in the flat Cartesian array + _x = x.ravel() + cart_idxs = [] + for atom_idx in args: + for k in range(3): + cart_idxs.append(3 * atom_idx + k) + variables = get_differentiable_vars( + values=[_x[idx] for idx in cart_idxs], + symbols=[str(idx) for idx in cart_idxs], + deriv_order=deriv_order, + ) + atom_vecs = [] + for pos_idx in range(len(args)): + atom_vecs.append( + DifferentiableVector3D(variables[pos_idx * 3 : pos_idx * 3 + 3]) + ) + return atom_vecs + + +class Primitive(ABC): + """Primitive internal coordinate""" + + is_constrained = False + + def __init__(self, *atom_indexes: int): + """A primitive internal coordinate that involves a number of atoms""" + self._atom_indexes = atom_indexes + + @abstractmethod + def _evaluate( + self, x: "CartesianCoordinates", deriv_order: DerivativeOrder + ) -> VectorHyperDual: + """ + The function that performs the main evaluation of the PIC, + and optionally returns derivative or second derivatives. + The returned hyper-dual must have the proper cartesian idxs + set. + + Args: + x: Cartesian coordinates + deriv_order: The order of derivatives requested - 0, 1 or 2 + + Returns: + (VectorHyperDual): The result, optionally containing derivatives + """ + + def __call__(self, x: "CartesianCoordinates") -> float: + """Return the value of this PIC given a set of cartesian coordinates""" + _x = x.ravel() + res = self._evaluate(_x, deriv_order=DerivativeOrder.zeroth) + return res.value + + def derivative( + self, + x: "CartesianCoordinates", + ) -> np.ndarray: + r""" + Calculate the derivatives with respect to cartesian coordinates + + .. math:: + + \frac{dq} + {d\boldsymbol{X}_{i, k}} {\Bigg\rvert}_{X=X0} + + where :math:`q` is the primitive coordinate and :math:`\boldsymbol{X}` + are the cartesian coordinates. + + ----------------------------------------------------------------------- + Arguments: + + x: Cartesian coordinate array of shape (N, ) + + Returns: + (np.ndarray): Derivative array of shape (N, ) + """ + _x = x.ravel() + res = self._evaluate(_x, deriv_order=DerivativeOrder.first) + derivs = np.zeros_like(_x, dtype=float) + for i in range(_x.shape[0]): + dqdx_i = res.differentiate_wrt(str(i)) + if dqdx_i is not None: + derivs[i] = dqdx_i + + return derivs + + def second_derivative( + self, + x: "CartesianCoordinates", + ) -> np.ndarray: + r""" + Calculate the second derivatives with respect to cartesian coordinates + + .. math:: + + \frac{d^2 q} + {d\boldsymbol{X}_{i, k}^2} {\Bigg\rvert}_{X=X0} + + where :math:`q` is the primitive coordinate and :math:`\boldsymbol{X}` + are the cartesian coordinates. + + ----------------------------------------------------------------------- + Arguments: + + x: Cartesian coordinate array of shape (N, ) + + Returns: + (np.ndarray): Second derivative matrix of shape (N, N) + """ + _x = x.ravel() + x_n = _x.shape[0] + res = self._evaluate(_x, deriv_order=DerivativeOrder.second) + derivs = np.zeros(shape=(x_n, x_n), dtype=float) + for i in range(x_n): + for j in range(x_n): + d2q_dx2_ij = res.differentiate_wrt(str(i), str(j)) + if d2q_dx2_ij is not None: + derivs[i, j] = d2q_dx2_ij + + return derivs + + @abstractmethod + def __eq__(self, other): + """Comparison of two primitive coordinates""" + + @property + def _ordered_idxs(self) -> Tuple[int, ...]: + """Atom indexes ordered smallest to largest""" + return tuple(sorted(self._atom_indexes)) + + +class ConstrainedPrimitive(Primitive, ABC): + """A primitive internal coordinate constrained to a value""" + + is_constrained = True + + @property + @abstractmethod + def _value(self) -> float: + """Value of the constraint that must be satisfied e.g. r0""" + + def is_satisfied( + self, + x: "CartesianCoordinates", + tol: float = 1e-4, + ) -> bool: + """Is this constraint satisfied to within an absolute tolerance""" + return abs(self.delta(x)) < tol + + def delta( + self, + x: "CartesianCoordinates", + ) -> float: + """Difference between the observed and required value""" + return self(x) - self._value + + +class _DistanceFunction(Primitive, ABC): + """Function of a distance between two atoms""" + + def __init__(self, i: int, j: int): + """ + Function of a distance between a pair of atoms + + .. math:: + + q = f(|\boldsymbol{X}_i - \boldsymbol{X}_j|) + + for a set of cartesian coordinates :math:`\boldsymbol{X}`. + ----------------------------------------------------------------------- + Arguments: + i: Atom index + + j: Atom index + """ + super().__init__(i, j) + + self.i = int(i) + self.j = int(j) + + def __eq__(self, other) -> bool: + """Equality of two distance functions""" + + return ( + isinstance(other, self.__class__) + and other._ordered_idxs == self._ordered_idxs + ) + + +class PrimitiveInverseDistance(_DistanceFunction): + r""" + Inverse distance between two atoms: + + .. math:: + + q = \frac{1} + {|\boldsymbol{X}_i - \boldsymbol{X}_j|} + """ + + def _evaluate( + self, x: "CartesianCoordinates", deriv_order: DerivativeOrder + ) -> VectorHyperDual: + """1 / |x_i - x_j|""" + vec_i, vec_j = _get_3d_vecs_from_atom_idxs( + self.i, self.j, x=x, deriv_order=deriv_order + ) + return 1.0 / (vec_i - vec_j).norm() # type: ignore + + def __repr__(self): + return f"InverseDistance({self.i}-{self.j})" + + +class PrimitiveDistance(_DistanceFunction): + r""" + Distance between two atoms: + + .. math:: + + q = |\boldsymbol{X}_i - \boldsymbol{X}_j| + """ + + def _evaluate( + self, x: "CartesianCoordinates", deriv_order: DerivativeOrder + ) -> VectorHyperDual: + """|x_i - x_j|""" + vec_i, vec_j = _get_3d_vecs_from_atom_idxs( + self.i, self.j, x=x, deriv_order=deriv_order + ) + return (vec_i - vec_j).norm() # type: ignore + + def __repr__(self): + return f"Distance({self.i}-{self.j})" + + +class ConstrainedPrimitiveDistance(ConstrainedPrimitive, PrimitiveDistance): + def __init__(self, i: int, j: int, value: float): + """ + Distance constrained to a value + + ----------------------------------------------------------------------- + Arguments: + + i: Atom index of the first atom + + j: Atom index of the second atom + + value: Required value of the constrained distance + """ + super().__init__(i=i, j=j) + + self._r0 = value + + @property + def _value(self) -> float: + return self._r0 + + def __repr__(self): + return f"ConstrainedDistance({self.i}-{self.j})" + + +class PrimitiveBondAngle(Primitive): + """ + Bond angle between three atoms, calculated with the + arccosine of the normalised dot product + """ + + def __init__(self, m: int, o: int, n: int): + """Bond angle m-o-n""" + super().__init__(m, o, n) + + self.m = int(m) + self.o = int(o) + self.n = int(n) + + def __eq__(self, other) -> bool: + """Equality of two distance functions""" + + return ( + isinstance(other, self.__class__) + and self.o == other.o + and other._ordered_idxs == self._ordered_idxs + ) + + def _evaluate( + self, x: "CartesianCoordinates", deriv_order: DerivativeOrder + ) -> VectorHyperDual: + """m - o - n angle""" + vec_m, vec_o, vec_n = _get_3d_vecs_from_atom_idxs( + self.m, self.o, self.n, x=x, deriv_order=deriv_order + ) + u = vec_m - vec_o + v = vec_n - vec_o + res = DifferentiableMath.acos(u.dot(v) / (u.norm() * v.norm())) + assert isinstance(res, VectorHyperDual) + return res + + def __repr__(self): + return f"Angle({self.m}-{self.o}-{self.n})" + + +class ConstrainedPrimitiveBondAngle(ConstrainedPrimitive, PrimitiveBondAngle): + def __init__(self, m: int, o: int, n: int, value: float): + """ + Angle (m-o-n) constrained to a value (in radians) + + ----------------------------------------------------------------------- + Arguments: + + m: Atom index + + o: Atom index + + n: Atom index + + value: Required value of the constrained angle + """ + super().__init__(m=m, o=o, n=n) + + self._theta0 = value + + @property + def _value(self) -> float: + return self._theta0 + + def __repr__(self): + return f"ConstrainedCAngle({self.m}-{self.o}-{self.n})" + + def __eq__(self, other: object): + return ( + super().__eq__(other) + and isinstance(other, ConstrainedPrimitiveBondAngle) + and np.isclose(self._theta0, other._theta0) + ) + + +class PrimitiveDihedralAngle(Primitive): + def __init__(self, m: int, o: int, p: int, n: int): + """Dihedral angle: m-o-p-n""" + super().__init__(m, o, p, n) + + self.m = int(m) + self.o = int(o) + self.p = int(p) + self.n = int(n) + + def __eq__(self, other) -> bool: + """Equality of two dihedral angles""" + return isinstance(other, self.__class__) and ( + self._atom_indexes == other._atom_indexes + or self._atom_indexes == tuple(reversed(other._atom_indexes)) + ) + + def _evaluate( + self, x: "CartesianCoordinates", deriv_order: DerivativeOrder + ) -> VectorHyperDual: + """Dihedral m-o-p-n""" + # https://en.wikipedia.org/wiki/Dihedral_angle#In_polymer_physics + _x = x.ravel() + vec_m, vec_o, vec_p, vec_n = _get_3d_vecs_from_atom_idxs( + self.m, self.o, self.p, self.n, x=_x, deriv_order=deriv_order + ) + u_1 = vec_o - vec_m + u_2 = vec_p - vec_o + u_3 = vec_n - vec_p + + norm_u2 = u_2.norm() + v1 = u_2.cross(u_3) + v2 = u_1.cross(u_2) + v3 = u_1 * norm_u2 + dihedral = DifferentiableMath.atan2(v3.dot(v1), v2.dot(v1)) + assert isinstance(dihedral, VectorHyperDual) + return dihedral + + def __repr__(self): + return f"Dihedral({self.m}-{self.o}-{self.p}-{self.n})" + + +class PrimitiveImproperDihedral(PrimitiveDihedralAngle): + """Out-of-Plan (improper) dihedral angles""" + + def __repr__(self): + return f"ImproperDihedral({self.m}-{self.o}-{self.p}-{self.n})" + + +class LinearBendType(Enum): + """For linear angles, there are two orthogonal directions""" + + BEND = 0 + COMPLEMENT = 1 + + +class LinearAngleBase(Primitive, ABC): + def __init__(self, m: int, o: int, n: int, r: int, axis: LinearBendType): + """Linear Bend: m-o-n""" + super().__init__(m, o, n, r) + self.m = int(m) + self.o = int(o) + self.n = int(n) + self.r = int(r) + + assert isinstance(axis, LinearBendType) + self.axis = axis + + def __eq__(self, other): + """Equality of two linear bend angles""" + return isinstance(other, self.__class__) and ( + self._atom_indexes == other._atom_indexes + and self.axis == other.axis + ) + + def _calc_linear_bend( + self, + m_vec: DifferentiableVector3D, + o_vec: DifferentiableVector3D, + n_vec: DifferentiableVector3D, + r_vec: DifferentiableVector3D, + ) -> VectorHyperDual: + """ + Evaluate the linear bend from the vector positions of the + atoms involved in the angle m, o, n, and the reference + atom (or dummy atom) r + + Args: + m_vec: + o_vec: + n_vec: + r_vec: + + Returns: + (VectorHyperDual): The value, optionally containing derivatives + """ + # As defined in J. Comput. Chem., 20(10), 1999, 1067 + o_m = m_vec - o_vec + o_n = n_vec - o_vec + o_r = r_vec - o_vec + + # eq.(44) p 1073 + u = o_m.cross(o_r) + u = u / u.norm() + + # eq. (46) and (47) p 1074 + if self.axis == LinearBendType.BEND: + res = u.dot(o_n) / o_n.norm() + elif self.axis == LinearBendType.COMPLEMENT: + res = u.dot(o_n.cross(o_m)) / (o_n.norm() * o_m.norm()) + else: + raise ValueError("Unknown axis for linear bend") + assert isinstance(res, VectorHyperDual) + return res + + +class PrimitiveLinearAngle(LinearAngleBase): + """Linear Angle w.r.t. a reference atom""" + + def _evaluate( + self, x: "CartesianCoordinates", deriv_order: DerivativeOrder + ) -> VectorHyperDual: + """Linear Bend angle m-o-n against reference atom r""" + + _x = x.ravel() + vec_m, vec_o, vec_n, vec_r = _get_3d_vecs_from_atom_idxs( + self.m, self.o, self.n, self.r, x=_x, deriv_order=deriv_order + ) + + return self._calc_linear_bend(vec_m, vec_o, vec_n, vec_r) + + def __repr__(self): + axis_str = "B" if self.axis == LinearBendType.BEND else "C" + return f"LinearBend{axis_str}({self.m}-{self.o}-{self.n}, {self.r})" + + +class PrimitiveDummyLinearAngle(LinearAngleBase): + """Linear bend with a dummy atom""" + + def __init__(self, m: int, o: int, n: int, axis: LinearBendType): + super().__init__(m, o, n, -1, axis) + + self._vec_r: Optional[DifferentiableVector3D] = None + + def _get_dummy_atom( + self, x: "CartesianCoordinates" + ) -> DifferentiableVector3D: + """Create the dummy atom r""" + _x = x.reshape(-1, 3) + cart_axes = [ + np.array([1.0, 0.0, 0.0]), + np.array([0.0, 1.0, 0.0]), + np.array([0.0, 0.0, 1.0]), + ] + + # choose cartesian axis with the lowest overlap with m-o vector + w = _x[self.m] - _x[self.o] + w /= np.linalg.norm(w) + overlaps = [] + for axis in cart_axes: + overlaps.append(np.dot(w, axis)) + cart_ax = cart_axes[np.argmin(np.abs(overlaps))] + + # place dummy atom perpendicular to m-o bond 1 A away + perp_axis = np.cross(cart_ax, w) + _o = _x[self.o] + dummy_point = _o + perp_axis / np.linalg.norm(perp_axis) + return DifferentiableVector3D(list(dummy_point)) + + def _evaluate( + self, x: "CartesianCoordinates", deriv_order: DerivativeOrder + ): + """Linear bend m-o-n against a dummy atom""" + if self._vec_r is None: + self._vec_r = self._get_dummy_atom(x) + + _x = x.ravel() + vec_m, vec_o, vec_n = _get_3d_vecs_from_atom_idxs( + self.m, self.o, self.n, x=_x, deriv_order=deriv_order + ) + + return self._calc_linear_bend(vec_m, vec_o, vec_n, self._vec_r) + + def __repr__(self): + axis_str = "B" if self.axis == LinearBendType.BEND else "C" + return f"LinearBend{axis_str}({self.m}-{self.o}-{self.n}, D)" + + +class CompositeBonds(Primitive): + """Linear Combination of several bond distances""" + + def __init__(self, bonds: List[Tuple[int, int]], coeffs: List[float]): + """ + Linear combination of a list of bonds and the corresponding + coefficients given as a list of real numbers + + Args: + bonds: A list of tuples (i, j) representing bonds + coeffs: A list of floating point coefficients in order + """ + super().__init__() + assert len(bonds) == len(coeffs), "Number of bonds != coefficients" + assert all(isinstance(bond, tuple) for bond in bonds) + assert all(len(bond) == 2 for bond in bonds) + assert all( + isinstance(bond[0], int) and isinstance(bond[1], int) + for bond in bonds + ) + assert len(set(bonds)) == len(bonds) + + self._bonds = list(bonds) + self._coeffs = [float(c) for c in coeffs] + + def _evaluate( + self, x: "CartesianCoordinates", deriv_order: DerivativeOrder + ) -> VectorHyperDual: + """Linear combination of bonds""" + all_idxs = list(itertools.chain(*self._bonds)) + unique_idxs = list(set(all_idxs)) + _x = x.ravel() + atom_vecs = _get_3d_vecs_from_atom_idxs( + *unique_idxs, x=_x, deriv_order=deriv_order + ) + + bonds_combined = None + for idx, (i, j) in enumerate(self._bonds): + atom_i = atom_vecs[unique_idxs.index(i)] + atom_j = atom_vecs[unique_idxs.index(j)] + if bonds_combined is None: + bonds_combined = self._coeffs[0] * (atom_i - atom_j).norm() + else: + bonds_combined += self._coeffs[idx] * (atom_i - atom_j).norm() + + assert isinstance(bonds_combined, VectorHyperDual) + return bonds_combined + + def __eq__(self, other): + """Equality of two linear combination of bonds""" + return ( + isinstance(other, self.__class__) + and set(zip(self._bonds)) == set(zip(other._bonds)) + and np.allclose(self._coeffs, other._coeffs) + ) # fmt: skip + + def __repr__(self): + return f"CombinationOfBonds(n={len(self._bonds)})" + + +class ConstrainedCompositeBonds(ConstrainedPrimitive, CompositeBonds): + """Constrained linear combindation of bonds""" + + def __init__( + self, bonds: List[Tuple[int, int]], coeffs: List[float], value: float + ): + """ + Linear combination of a list of bonds and the corresponding + coefficients given as a list of real numbers + + Args: + bonds: A list of tuples (i, j) representing bonds + coeffs: A list of floating point coefficients in order + value: The target value for this coordinate + """ + CompositeBonds.__init__(self, bonds=bonds, coeffs=coeffs) + self._r0 = value + + @property + def _value(self) -> float: + return self._r0 + + def __repr__(self): + return f"ConstrainedCombinationOfBonds(n={len(self._bonds)})" diff --git a/autodE/source/autode/opt/optimisers/__init__.py b/autodE/source/autode/opt/optimisers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..23d5f25698b0339184a932cdd05302960545a79d --- /dev/null +++ b/autodE/source/autode/opt/optimisers/__init__.py @@ -0,0 +1,20 @@ +from autode.opt.optimisers.base import NDOptimiser, ConvergenceParams +from autode.opt.optimisers.rfo import RFOptimiser +from autode.opt.optimisers.prfo import PRFOptimiser +from autode.opt.optimisers.crfo import CRFOptimiser +from autode.opt.optimisers.qa import QAOptimiser +from autode.opt.optimisers.steepest_descent import ( + CartesianSDOptimiser, + DIC_SD_Optimiser, +) + +__all__ = [ + "NDOptimiser", + "ConvergenceParams", + "RFOptimiser", + "PRFOptimiser", + "CRFOptimiser", + "QAOptimiser", + "CartesianSDOptimiser", + "DIC_SD_Optimiser", +] diff --git a/autodE/source/autode/opt/optimisers/base.py b/autodE/source/autode/opt/optimisers/base.py new file mode 100644 index 0000000000000000000000000000000000000000..d01a306e44f9168fceb0891df14fec047d3ec494 --- /dev/null +++ b/autodE/source/autode/opt/optimisers/base.py @@ -0,0 +1,1312 @@ +import os +import pickle +from dataclasses import dataclass, fields +import numpy as np + +from abc import ABC, abstractmethod +from zipfile import ZipFile, is_zipfile +from collections import deque +from typing import Type, List, Union, Optional, Callable, Any +from typing import TYPE_CHECKING, Iterator, Literal + +from autode.log import logger +from autode.config import Config +from autode.values import GradientRMS, PotentialEnergy, method_string, Distance +from autode.opt.coordinates.base import OptCoordinates +from autode.opt.optimisers.hessian_update import NullUpdate +from autode.exceptions import CalculationException +from autode.plotting import plot_optimiser_profile + +if TYPE_CHECKING: + from autode.species.species import Species + from autode.wrappers.methods import Method + from autode.opt.coordinates import OptCoordinates + from autode.opt.optimisers.hessian_update import HessianUpdater + + +class BaseOptimiser(ABC): + """Base abstract class for an optimiser""" + + @property + @abstractmethod + def converged(self) -> bool: + """Has this optimisation converged""" + + @property + @abstractmethod + def last_energy_change(self) -> PotentialEnergy: + """The energy change on between the final two optimisation cycles""" + + def run(self, *args: Any, **kwargs: Any) -> None: + raise NotImplementedError + + @property + def final_coordinates(self): + raise NotImplementedError + + +class Optimiser(BaseOptimiser, ABC): + """Abstract base class for an optimiser""" + + def __init__( + self, + maxiter: int, + coords: Optional["OptCoordinates"] = None, + callback: Optional[Callable] = None, + callback_kwargs: Optional[dict] = None, + ): + """ + Optimiser + + ---------------------------------------------------------------------- + Arguments: + maxiter: Maximum number of iterations to perform + + coords: Coordinates to use in the optimisation + e.g. CartesianCoordinates. If None then will initialise the + coordinates from _species + + callback: Function that will be called after every step. First + called after initialisation and before the first step. + Takes the current coordinates (which have energy (e), + gradient (g) and hessian (h) attributes) as the only + positional argument + + callback_kwargs: Keyword arguments to pass to the callback function + """ + if int(maxiter) <= 0: + raise ValueError( + "An optimiser must be able to run at least one " + f"step, but tried to set maxiter = {maxiter}" + ) + + self._callback = _OptimiserCallbackFunction(callback, callback_kwargs) + + self._maxiter = int(maxiter) + self._n_cores: int = Config.n_cores + + self._history = OptimiserHistory() + + self._coords = coords + self._species: Optional["Species"] = None + self._method: Optional["Method"] = None + + @classmethod + @abstractmethod + def optimise( + cls, + species: "Species", + method: "Method", + n_cores: Optional[int] = None, + coords: Optional[OptCoordinates] = None, + **kwargs, + ) -> None: + """ + Optimise a species using a method + + .. code-block:: Python + + >>> import autode as ade + >>> mol = ade.Molecule(smiles='C') + >>> Optimiser.optimise(mol,method=ade.methods.ORCA()) + """ + + @property + def optimiser_params(self) -> dict: + """ + The parameters which are needed to intialise the optimiser and + will be saved in the optimiser trajectory + """ + return {"maxiter": self._maxiter} + + def run( + self, + species: "Species", + method: "Method", + n_cores: Optional[int] = None, + name: Optional[str] = None, + ) -> None: + """ + Run the optimiser. Updates species.atoms and species.energy + + ---------------------------------------------------------------------- + Arguments: + species: Species to optimise + + method: Method to use to calculate energies/gradients/hessians. + Calculations will use method.keywords.grad for gradient + calculations + + n_cores: Number of cores to use for calculations. If None then use + autode.Config.n_cores + + name: The name of the optimisation save file + """ + self._n_cores = n_cores if n_cores is not None else Config.n_cores + self._initialise_species_and_method(species, method) + assert self._species is not None, "Species must be set" + + if not self._space_has_degrees_of_freedom: + logger.info("Optimisation is in a 0D space – terminating") + return None + + if name is None: + name = f"{self._species.name}_opt_trj.zip" + + self._history.open(filename=name) + self._history.save_opt_params(self.optimiser_params) + self._initialise_run() + + logger.info( + f"Using {self._method} to optimise {self._species.name} " + f"with {self._n_cores} cores using {self._maxiter} max " + f"iterations" + ) + + while not self.converged: + self._callback(self._coords) + self._step() # Update self._coords + self._update_gradient_and_energy() # Update self._coords.g + self._log_convergence() + + if self._exceeded_maximum_iteration: + break + + logger.info(f"Converged: {self.converged}, in {self.iteration} cycles") + self._history.close() + return None + + @property + def iteration(self) -> int: + """ + Iteration of the optimiser, which is equal to the length of the history + minus one, for zero indexing. + + ----------------------------------------------------------------------- + Returns: + (int): Current iteration + """ + return len(self._history) - 1 + + def _initialise_species_and_method( + self, + species: "Species", + method: "Method", + ) -> None: + """ + Initialise the internal species and method. They must be the correct + types + + ----------------------------------------------------------------------- + Raises: + (ValueError): For incorrect types + """ + from autode.species.species import Species + from autode.wrappers.methods import Method + + if not isinstance(species, Species): + raise ValueError( + f"{species} must be a autoode.Species instance " + f"but had {type(species)}" + ) + + if not isinstance(method, Method): + raise ValueError( + f"{method} must be a autoode.wrappers.base.Method " + f"instance but had {type(method)}" + ) + + if species.constraints.n_cartesian > 0: + raise NotImplementedError + + self._method, self._species = method, species + return None + + def _update_gradient_and_energy(self) -> None: + """ + Update the gradient of the energy with respect to the coordinates + using the method. Will transform from the current coordinates type + to Cartesian coordinates to perform the calculation, then back. + + ----------------------------------------------------------------------- + Raises: + (autode.exceptions.CalculationException): + """ + assert self._species and self._coords is not None and self._method + + from autode.calculations import Calculation + + # TODO: species.calc_grad() method + + # Calculations need to be performed in cartesian coordinates + if self._coords is not None: + self._species.coordinates = self._coords.to("cart") + + grad = Calculation( + name=f"{self._species.name}_opt_{self.iteration}", + molecule=self._species, + method=self._method, + keywords=self._method.keywords.grad, + n_cores=self._n_cores, + ) + grad.run() + grad.clean_up(force=True, everything=True) + + if self._species.gradient is None: + raise CalculationException( + "Calculation failed to calculate a gradient. " + "Cannot continue!" + ) + + self._coords.e = self._species.energy + self._coords.update_g_from_cart_g(arr=self._species.gradient) + return None + + def _update_hessian_gradient_and_energy(self) -> None: + """ + Update the energy, gradient and Hessian using the method. Will + transform from the current coordinates type to Cartesian coordinates + to perform the calculation, then back. Uses a numerical Hessian if + analytic Hessians are not implemented for this method. Does not + perform a Hessian evaluation if the molecule's energy is evaluated + at the same level of theory that would be used for the Hessian + evaluation. + + ----------------------------------------------------------------------- + Raises: + (autode.exceptions.CalculationException): + """ + assert self._species and self._coords is not None and self._method + should_calc_hessian = True + + if ( + _energy_method_string(self._species) + == method_string(self._method, self._method.keywords.hess) + and self._species.hessian is not None + ): + logger.info( + "Have a calculated the energy at the same level of " + "theory as this optimisation and a present Hessian. " + "Not calculating a new Hessian" + ) + should_calc_hessian = False + + self._update_gradient_and_energy() + + if should_calc_hessian: + self._update_hessian() + else: + self._coords.update_h_from_cart_h( + self._species.hessian.to("Ha Å^-2") # type: ignore + ) + return None + + def _update_hessian(self) -> None: + """Update the Hessian of a species""" + assert self._species and self._coords is not None and self._method + + species = self._species.new_species( + name=f"{self._species.name}_opt_{self.iteration}" + ) + species.coordinates = self._coords.to("cartesian") + + species.calc_hessian( + method=self._method, + keywords=self._method.keywords.hess, + n_cores=self._n_cores, + ) + assert species.hessian is not None, "Failed to calculate H" + + self._species.hessian = species.hessian.copy() + self._coords.update_h_from_cart_h(self._species.hessian.to("Ha Å^-2")) + + @property + def _space_has_degrees_of_freedom(self) -> bool: + """Does this optimisation space have any degrees of freedom""" + return True + + @property + def _coords(self) -> Optional[OptCoordinates]: + """ + Current set of coordinates this optimiser is using + """ + if len(self._history) == 0: + logger.warning("Optimiser had no history, thus no coordinates") + return None + + return self._history.final + + @_coords.setter + def _coords(self, value: Optional[OptCoordinates]) -> None: + """ + Set a new set of coordinates of this optimiser, will append to the + current history. + + ----------------------------------------------------------------------- + Arguments: + value (OptCoordinates | None): + + Raises: + (ValueError): For invalid input + """ + if value is None: + return + + elif isinstance(value, OptCoordinates): + self._history.add(value.copy()) + + else: + raise ValueError( + "Cannot set the optimiser coordinates with " f"{value}" + ) + + @abstractmethod + def _step(self) -> None: + """ + Take a step with this optimiser. Should only act on self._coords + using the gradient (self._coords.g) and hessians (self._coords.h) + """ + + @abstractmethod + def _initialise_run(self) -> None: + """ + Initialise all attributes required to call self._step() + + For example: + + self._coords (from self._species) + self._coords.g + self._coords.h + """ + + @property + @abstractmethod + def converged(self) -> bool: + """Has this optimisation converged""" + + @property + def last_energy_change(self) -> PotentialEnergy: + """Last ∆E found in this""" + + if self.iteration > 0: + final_e = self._history.final.e + penultimate_e = self._history.penultimate.e + if final_e is not None and penultimate_e is not None: + return PotentialEnergy(final_e - penultimate_e, units="Ha") + + if self.converged: + logger.warning( + "Optimiser was converged in less than two " + "cycles. Assuming an energy change of 0" + ) + return PotentialEnergy(0) + + return PotentialEnergy(np.inf) + + @property + def final_coordinates(self) -> Optional["OptCoordinates"]: + return None if len(self._history) == 0 else self._history.final + + def _log_convergence(self) -> None: + """Log the iterations in the form: + Iteration |∆E| / kcal mol-1 ||∇E|| / Ha Å-1 + """ + + @property + def _has_coordinates_and_gradient(self) -> bool: + """Does this optimiser have defined coordinates and a gradient?""" + return self._coords is not None and self._coords.g is not None + + @property + def _exceeded_maximum_iteration(self) -> bool: + """ + Has this optimiser exceeded the maximum number of iterations + allowed? + + ----------------------------------------------------------------------- + Returns: + (bool): + """ + if self.iteration >= self._maxiter: + logger.warning( + f"Reached the maximum number of iterations " + f"*{self._maxiter}*. Did not converge" + ) + return True + + else: + return False + + +class NullOptimiser(BaseOptimiser): + """An optimiser that does nothing""" + + @property + def converged(self) -> bool: + return False + + @property + def last_energy_change(self) -> PotentialEnergy: + return PotentialEnergy(np.inf) + + def run(self, **kwargs: Any) -> None: + pass + + @property + def final_coordinates(self): + raise RuntimeError("A NullOptimiser has no coordinates") + + +ConvergenceTolStr = Literal["loose", "normal", "tight", "verytight"] + + +@dataclass +class ConvergenceParams: + """ + Various convergence parameters for optimisers and some common + preset convergence tolerances + + Args: + abs_d_e: Absolute change in energy, |E_i - E_i-1| + rms_g: RMS of the gradient, RMS(∇E) + max_g: Maximum component of gradient, max(∇E) + rms_s: RMS of the last step, RMS(x_i - x_i-1) + max_s: Maximum component of last step, max(x_i - x_i-1) + strict: Whether all criteria must be converged strictly. + If False, convergence is signalled when some criteria + are overachieved and others are close to convergence + + """ + + abs_d_e: Optional[PotentialEnergy] = None + rms_g: Optional[GradientRMS] = None + max_g: Optional[GradientRMS] = None + rms_s: Optional[Distance] = None + max_s: Optional[Distance] = None + strict: bool = False + + @property + def _num_attrs(self) -> List[str]: + """Numerical attributes of this dataclass, in order""" + return ["abs_d_e", "rms_g", "max_g", "rms_s", "max_s"] + + def __post_init__(self): + """Type checking and sanity checks on parameters""" + + # convert units for easier comparison + self._to_base_units() + self.strict = bool(self.strict) + # RMS(g) is the most basic criteria that is always needed + if self.rms_g is None: + raise ValueError( + "At least the RMS gradient criteria has to be defined!" + ) + + for attr in self._num_attrs: + if getattr(self, attr) is None: + continue + if not getattr(self, attr) > 0: + raise ValueError( + f"Value of {attr} should be positive" + f" but set to {getattr(self, attr)}!" + ) + + def _to_base_units(self) -> None: + """ + Convert all set criteria to the default units in terms of + Hartree and Angstrom, and also ensure everything has units + """ + if self.abs_d_e is not None: + self.abs_d_e = PotentialEnergy(self.abs_d_e).to("Ha") + if self.rms_g is not None: + self.rms_g = GradientRMS(self.rms_g).to("Ha/ang") + if self.max_g is not None: + self.max_g = GradientRMS(self.max_g).to("Ha/ang") + if self.rms_s is not None: + self.rms_s = Distance(self.rms_s).to("ang") + if self.max_s is not None: + self.max_s = Distance(self.max_s).to("ang") + return None + + @classmethod + def from_preset(cls, preset_name: str) -> "ConvergenceParams": + """ + Obtains preset values of convergence criteria - given as + "loose", "normal", "tight" and "verytight". + + Args: + preset_name: Must be one of the strings "loose", "normal" + "tight" or "verytight" + + Returns: + (ConvergenceCriteria): Optimiser convergence criteria, with + preset values + """ + # NOTE: Taken from ORCA + preset_dicts = { + "loose": { + "abs_d_e": PotentialEnergy(3e-5, "Ha"), + "rms_g": GradientRMS(5e-4, "Ha/bohr").to("Ha/ang"), + "max_g": GradientRMS(2e-3, "Ha/bohr").to("Ha/ang"), + "rms_s": Distance(7e-3, "bohr").to("ang"), + "max_s": Distance(1e-2, "bohr").to("ang"), + }, + "normal": { + "abs_d_e": PotentialEnergy(5e-6, "Ha"), + "rms_g": GradientRMS(1e-4, "Ha/bohr").to("Ha/ang"), + "max_g": GradientRMS(3e-4, "Ha/bohr").to("Ha/ang"), + "rms_s": Distance(2e-3, "bohr").to("ang"), + "max_s": Distance(4e-3, "bohr").to("ang"), + }, + "tight": { + "abs_d_e": PotentialEnergy(1e-6, "Ha"), + "rms_g": GradientRMS(3e-5, "Ha/bohr").to("Ha/ang"), + "max_g": GradientRMS(1e-4, "Ha/bohr").to("Ha/ang"), + "rms_s": Distance(6e-4, "bohr").to("ang"), + "max_s": Distance(1e-3, "bohr").to("ang"), + }, + "verytight": { + "abs_d_e": PotentialEnergy(2e-7, "Ha"), + "rms_g": GradientRMS(8e-6, "Ha/bohr").to("Ha/ang"), + "max_g": GradientRMS(3e-5, "Ha/bohr").to("Ha/ang"), + "rms_s": Distance(1e-4, "bohr").to("ang"), + "max_s": Distance(2e-4, "bohr").to("ang"), + }, + } + + allowed_strs = list(preset_dicts.keys()) + preset_name = preset_name.strip().lower() + if preset_name not in allowed_strs: + raise ValueError( + f"Unknown preset convergence: {preset_name}, please select" + f" from {allowed_strs}" + ) + + return cls(**preset_dicts[preset_name]) + + def __mul__(self, factors: List[float]): + """Multiply a set of criteria with ordered list of numerical factors""" + assert len(factors) == len(self._num_attrs) + kwargs = {} + for idx, attr in enumerate(self._num_attrs): + c = getattr(self, attr) + if c is not None: + kwargs[attr] = getattr(self, attr) * factors[idx] + else: + kwargs[attr] = None + return ConvergenceParams(**kwargs, strict=self.strict) + + def are_satisfied(self, other: "ConvergenceParams") -> List[bool]: + """ + Return an elementwise comparison between the current criteria + and another set of parameters (comparing only numerical attributes) + + Args: + other: Another set of parameters + + Returns: + (list[bool]): List containing True or False + """ + are_satisfied = [] + + # unset criteria are always satisfied + for attr in self._num_attrs: + c = getattr(self, attr) + v = getattr(other, attr) + if c is None: + are_satisfied.append(True) + else: + are_satisfied.append(float(v) <= float(c)) + return are_satisfied + + def meets_criteria(self, other: "ConvergenceParams") -> bool: + """ + Does a set of parameters satisfy the current convergence criteria? + Will signal convergence if gradient or energy change are overachieved + or all other criteria except energy is satisfied + + Args: + other (ConvergenceParams): Another set of parameters to be + checked against the current set + + Returns: + (bool): + """ + # everything satisfied - simplest case + if all(self.are_satisfied(other)): + return True + + # strict = everything must be converged + elif self.strict: + return False + + if all((self * [0.5, 0.5, 0.8, 3, 3]).are_satisfied(other)): + logger.warning( + "Overachieved gradient and energy convergence, reasonable " + "convergence on step size." + ) + return True + + if all((self * [1.5, 0.1, 0.2, 2, 2]).are_satisfied(other)): + logger.warning( + "Gradient is one order of magnitude below convergence, " + "other parameter(s) are almost converged." + ) + return True + + if all((self * [3, 0.7, 0.7, 1, 1]).are_satisfied(other)): + logger.warning( + "Everything except energy has been converged. Reasonable" + " convergence on energy" + ) + return True + + return False + + +class NDOptimiser(Optimiser, ABC): + """Abstract base class for an optimiser in N-dimensions""" + + def __init__( + self, + maxiter: int, + conv_tol: Union[ConvergenceParams, ConvergenceTolStr], + coords: Optional[OptCoordinates] = None, + **kwargs, + ): + """ + Geometry optimiser. Signature follows that in scipy.minimize so + species and method are keyword arguments. Converged when both energy + and gradient criteria are met. + + ---------------------------------------------------------------------- + Arguments: + maxiter (int): Maximum number of iterations to perform + + conv_tol (ConvergenceParams|ConvergenceTolStr): Convergence tolerances, + indicating thresholds for absolute energy change (|E_i+1 - E_i|), + RMS and max. gradients (∇E) and RMS and max. step size (Δx) + Either supplied as a dictionary or a ConvergenceParams object + + See Also: + + :py:meth:`Optimiser ` + :py:meth:`ConvergenceParams ` + """ + super().__init__(maxiter=maxiter, coords=coords, **kwargs) + + if isinstance(conv_tol, str): + conv_tol = ConvergenceParams.from_preset(conv_tol) + self.conv_tol = conv_tol + self._hessian_update_types: List[Type[HessianUpdater]] = [NullUpdate] + + @property + def conv_tol(self) -> "ConvergenceParams": + """ + All convergence parameters for this optimiser. If numerical + values are unset, they appear as infinity. + + Returns: + (ConvergenceParams): + """ + return self._conv_tol + + @conv_tol.setter + def conv_tol(self, value: Union["ConvergenceParams", ConvergenceTolStr]): + """ + Set the convergence parameters for this optimiser. + + Args: + value (ConvergenceParams|str): + """ + if isinstance(value, str): + self._conv_tol = ConvergenceParams.from_preset(value) + elif isinstance(value, ConvergenceParams): + self._conv_tol = value + else: + raise ValueError( + "Convergence tolerance should be of type ConvergenceParams" + f" or a preset string, but assigned {type(value)}" + ) + + @property + def optimiser_params(self): + """Optimiser params to save""" + return {"maxiter": self._maxiter, "conv_tol": self.conv_tol} + + @classmethod + def optimise( + cls, + species: "Species", + method: "Method", + n_cores: Optional[int] = None, + coords: Optional[OptCoordinates] = None, + maxiter: int = 100, + conv_tol: Union[ConvergenceParams, ConvergenceTolStr] = "normal", + **kwargs, + ) -> None: + """ + Convenience function for constructing and running an optimiser + + ---------------------------------------------------------------------- + Arguments: + species (Species): + + method (autode.methods.Method): + + maxiter (int): Maximum number of iteration to perform + + conv_tol (ConvergenceParams|ConvergenceTolStr): Convergence parameters + for the absolute energy change, RMS and max gradient, + and RMS and max step sizes. + + coords (OptCoordinates | None): Coordinates to optimise in + + n_cores (int | None): Number of cores to run energy/gradient/hessian + evaluations. If None then use ade.Config.n_cores + + kwargs (Any): Additional keyword arguments to pass to the + constructor + + Raises: + (ValueError | RuntimeError): + """ + + optimiser = cls( + maxiter=maxiter, conv_tol=conv_tol, coords=coords, **kwargs + ) + optimiser.run(species, method, n_cores=n_cores) + + return None + + @property + def _space_has_degrees_of_freedom(self) -> bool: + return True if self._species is None else self._species.n_atoms > 1 + + @property + def converged(self) -> bool: + """ + Is this optimisation converged? Must be converged based on energy, gradient + and step size criteria. + + ----------------------------------------------------------------------- + Returns: + (bool): Converged? + """ + if self._species is not None and self._species.n_atoms == 1: + return True # Optimisation 0 DOF is always converged + + assert self._coords is not None, "Must have coordinates!" + curr_params = self._history.conv_params() + + constrs_met = ( + self._coords.n_constraints == self._coords.n_satisfied_constraints + ) + return constrs_met and self.conv_tol.meets_criteria(curr_params) + + def clean_up(self) -> None: + """ + Clean up by removing the trajectory file on disk + """ + self._history.clean_up() + + @classmethod + def from_file(cls, filename: str) -> "NDOptimiser": + """ + Create an optimiser from a trajectory file i.e. reload a saved state + """ + hist = OptimiserHistory.load(filename) + optimiser = cls(**hist.get_opt_params()) + optimiser._history = hist + return optimiser + + def _log_convergence(self) -> None: + """Log the convergence of the all convergence parameters""" + assert self._coords is not None, "Must have coordinates!" + + curr_params = self._history.conv_params() + assert curr_params.abs_d_e is not None + + conv_msgs = [ + "(YES)" if param_converged else "(NO)" + for param_converged in self.conv_tol.are_satisfied(curr_params) + ] + log_string1 = ( + f"iter# {self.iteration} |dE|=" + f"{curr_params.abs_d_e.to('kcalmol'):.5f} kcal/mol {conv_msgs[0]}" + f" RMS(g)={curr_params.rms_g:.5f} Ha/Å {conv_msgs[1]} " + ) + log_string2 = ( + f"max(g)={curr_params.max_g:.5f} Ha/Å {conv_msgs[2]} " + f"RMS(dx)={curr_params.rms_s:.5f} Å {conv_msgs[3]} " + f"max(dx)={curr_params.max_s:.5f} Å {conv_msgs[4]}" + ) + logger.info(log_string1) + logger.info(log_string2) + return None + + def plot_optimisation( + self, + filename: Optional[str] = None, + plot_energy: bool = True, + plot_rms_grad: bool = True, + ) -> None: + """ + Draw the plot of the energies and/or rms_gradients of + the optimisation so far + + ---------------------------------------------------------------------- + Args: + filename (str): Name of the file to plot + plot_energy (bool): Whether to plot energy + plot_rms_grad (bool): Whether to plot RMS of gradient + """ + assert self._species is not None, "Must have a species to plot" + + if self.iteration < 1: + logger.warning("Less than 2 points, cannot draw optimisation plot") + return None + + if not self.converged: + logger.warning( + "Optimisation is not converged, drawing a plot " + "of optimiser profile until current iteration" + ) + + filename = ( + f"{self._species.name}_opt_plot.pdf" + if filename is None + else str(filename) + ) + + plot_optimiser_profile( + self._history, + filename=filename, + plot_energy=plot_energy, + plot_rms_grad=plot_rms_grad, + ) + return None + + def print_geometries(self, filename: Optional[str] = None) -> None: + """ + Writes the trajectory of the optimiser in .xyz format + + Args: + filename (str|None): Name of the trajectory file (xyz), + if not given, generates name from species + """ + assert self._species is not None + if self.iteration < 1: + logger.warning( + "Optimiser did no steps, not saving .xyz trajectory" + ) + return None + + filename = ( + f"{self._species.name}_opt.trj.xyz" + if filename is None + else str(filename) + ) + print_geometries_from( + self._history, species=self._species, filename=filename + ) + return None + + +class OptimiserHistory: + """ + Sequential trajectory of coordinates with a maximum length for + storage on memory. Shunts data to disk if trajectory file is + opened, otherwise old coordinates more than the maximum number + are lost. + """ + + def __init__(self, maxlen: Optional[int] = 2) -> None: + self._filename: Optional[str] = None # filename with abs. path + self._memory: deque = deque(maxlen=maxlen) # coords in mem + self._maxlen = maxlen if maxlen is not None else float("inf") + self._is_closed = False # whether trajectory is open + self._len = 0 # count of total number of coords + + @property + def final(self): + """ + Last set of coordinates in memory + + ----------------------------------------------------------------------- + Returns: + (OptCoordinates): + """ + if len(self._memory) < 1: + raise IndexError( + "Cannot obtain the final set of " + f"coordinates, memory is empty" + ) + return self._memory[-1] + + @property + def penultimate(self): + """ + Last but one set of coordinates from memory (the penultimate set) + + ----------------------------------------------------------------------- + Returns: + (OptCoordinates): + """ + if len(self._memory) < 2: + raise IndexError( + "Cannot obtain the penultimate set of " + f"coordinates, only had {len(self._memory)}" + ) + return self._memory[-2] + + @property + def _n_stored(self) -> int: + """Number of coordinates stored on disk""" + if self._filename is None: + return 0 + + with ZipFile(self._filename, "r") as file: + names = file.namelist() + n_coords = 0 + for name in names: + if name.startswith("coords_") and int(name[7:]) >= 0: + n_coords += 1 + return n_coords + + def __len__(self): + """How many coordinates have been put into this trajectory""" + return self._len + + def open(self, filename: str): + """ + Initialise the trajectory file and write it on disk. + + Args: + filename (str): The name of the trajectory file, + should be .zip, and NOT a path + """ + if self._filename is not None: + raise RuntimeError("Already initialised, cannot initialise again!") + + # filename should not be a path + assert "\\" not in filename and "/" not in filename + if not filename.lower().endswith(".zip"): + filename += ".zip" + + if os.path.exists(filename): + logger.warning(f"File {filename} already exists, overwriting") + os.remove(filename) + + # get the full path so that it is robust to os.chdir + self._filename = os.path.abspath(filename) + + # write a header like file to help identify + with ZipFile(filename, "w") as file: + with file.open("ade_opt_trj", "w") as fh: + fh.write("Trajectory from autodE".encode("utf-8")) + return None + + @classmethod + def load(cls, filename: str): + """ + Reload the state of the trajectory from a file + + Args: + filename: The name of the trajectory .zip file, + could also be a relative path + + Returns: + + """ + trj = cls() + if not filename.lower().endswith(".zip"): + filename += ".zip" + if not os.path.isfile(filename): + raise FileNotFoundError(f"The file {filename} does not exist!") + if not is_zipfile(filename): + raise ValueError( + f"The file {filename} is not a valid trajectory file" + ) + with ZipFile(filename, "r") as file: + names = file.namelist() + if "ade_opt_trj" not in names: + raise ValueError( + f"The file {filename} is not an autodE trajectory!" + ) + # handle paths with env vars or w.r.t. home dir + trj._filename = os.path.abspath( + os.path.expanduser(os.path.expandvars(filename)) + ) + trj._len = trj._n_stored + trj._is_closed = True + # load the last two into memory + if trj._len < 2: + load_idxs = [trj._len - 1] + else: + load_idxs = [trj._len - 2, trj._len - 1] + with ZipFile(trj._filename, "r") as file: + for idx in load_idxs: + with file.open(f"coords_{idx}") as fh: + trj._memory.append(pickle.load(fh)) + + return trj + + def clean_up(self): + """Remove the disk file associated with this history""" + os.remove(self._filename) + return None + + def save_opt_params(self, params: dict): + """ + Save optimiser parameters given as a dict into the trajectory + savefile + + Args: + params (dict): + """ + assert isinstance(params, dict) + if self._filename is None: + raise RuntimeError("File not opened - cannot store data") + + # python's ZipFile does not allow overwriting files + with ZipFile(self._filename, "a") as file: + names = file.namelist() + if "opt_params" in names: + raise FileExistsError( + "Optimiser parameters are already stored -" + " cannot overwrite!" + ) + with file.open("opt_params", "w") as fh: + pickle.dump(params, fh, pickle.HIGHEST_PROTOCOL) + + return None + + def get_opt_params(self) -> dict: + """ + Retrieve the stored optimiser parameters from the trajectory + file + + Returns: + (dict): Dictionary of optimiser parameters + """ + if self._filename is None: + raise RuntimeError("File not opened - cannot get data") + + # python's ZipFile does not allow overwriting files + with ZipFile(self._filename, "r") as file: + names = file.namelist() + if "opt_params" not in names: + raise FileNotFoundError("Optimiser parameters are not found!") + with file.open("opt_params", "r") as fh: + data = pickle.load(fh) + + return data + + def add(self, coords: Optional["OptCoordinates"]) -> None: + """ + Add a new set of coordinates to this trajectory + + Args: + coords (OptCoordinates): The set of coordinates to be added + """ + if coords is None: + return None + elif not isinstance(coords, OptCoordinates): + raise ValueError("item added must be OptCoordinates") + + if self._is_closed: + raise RuntimeError("Cannot add to closed OptimiserHistory") + + self._len += 1 + # check if we need to push last coords to disk or can skip + if len(self._memory) < self._maxlen or self._filename is None: + self._memory.append(coords) + return None + + n_stored = self._n_stored + with ZipFile(self._filename, "a") as file: + with file.open(f"coords_{n_stored}", "w") as fh: + pickle.dump(self._memory[0], fh, pickle.HIGHEST_PROTOCOL) + self._memory.append(coords) + return None + + def close(self): + """ + Close the Optimiser history by putting the coordinates still + in memory onto disk + """ + if self._filename is None: + raise RuntimeError("Cannot close - had no trajectory file!") + + idx = self._n_stored + with ZipFile(self._filename, "a") as file: + for coords in self._memory: + with file.open(f"coords_{idx}", "w") as fh: + pickle.dump(coords, fh, pickle.HIGHEST_PROTOCOL) + idx += 1 + + self._is_closed = True + return None + + def __getitem__(self, item: int) -> Optional[OptCoordinates]: + """ + Access a coordinate from this trajectory, either from stored + data on disk, or from the memory. Only returns Cartesian + coordinates to ensure type consistency. + + Args: + item (int): Must be integer and not a slice + + Returns: + (CartesianCoordinates|None): The coordinates if found, None + if the file does not exist and coordinate is not + in memory + + Raises: + NotImplementedError: If slice is used + IndexError: If requested index does not exist + """ + if isinstance(item, slice): + raise NotImplementedError + elif isinstance(item, int): + pass + else: + raise ValueError("Index has to be type int") + + if item < 0: + item += self._len + if item < 0 or item >= self._len: + raise IndexError("Array index out of range") + + # read directly from memory if possible + if item >= (self._len - self._maxlen): + return self._memory[item - self._len] + + # have to read from disk now, return None if no file + if self._filename is None: + return None + + with ZipFile(self._filename, "r") as file: + with file.open(f"coords_{item}") as fh: + coords = pickle.load(fh) + + return coords + + def __iter__(self): + """ + Iterate through the coordinates of this trajectory + """ + for i in range(len(self)): + yield self[i] + + def __reversed__(self): + """ + Reversed iteration through the coordinates + """ + for i in reversed(range(len(self))): + yield self[i] + + def conv_params(self, idx: int = -1) -> ConvergenceParams: + """ + Calculate the convergence parameters for the coordinates at + specified index (default -1 i.e. the last set of coordinates) + + Args: + idx (int): Index of the set of coordinates for which to + calculate the parameter + + Returns: + (ConvergenceParams): + """ + # NOTE: Internal coordinates have inconsistent units, so we + # calculate step sizes and gradients in Cartesian coordinates + coords_l = self[idx] + assert coords_l is not None + g_x = coords_l.cart_proj_g + if g_x is not None: + rms_g = np.sqrt(np.mean(np.square(g_x))) + max_g = np.max(np.abs(g_x)) + else: + rms_g = max_g = np.inf + + if len(self) > 1: + coords_k = self[idx - 1] + assert coords_k is not None + assert coords_l.e is not None and coords_k.e is not None + abs_d_e = PotentialEnergy(abs(coords_l.e - coords_k.e)) + delta_x = coords_l.to("cart") - coords_k.to("cart") + rms_s = np.sqrt(np.mean(np.square(delta_x))) + max_s = np.max(np.abs(delta_x)) + else: + abs_d_e = rms_s = max_s = np.inf + return ConvergenceParams( + abs_d_e=abs_d_e, rms_g=rms_g, max_g=max_g, rms_s=rms_s, max_s=max_s + ) + + +class ExternalOptimiser(BaseOptimiser, ABC): + @property + @abstractmethod + def converged(self) -> bool: + """Has this optimisation has converged""" + + @property + @abstractmethod + def last_energy_change(self) -> PotentialEnergy: + """The final energy change in this optimisation""" + + +class _OptimiserCallbackFunction: + def __init__(self, f: Optional[Callable], kwargs: Optional[dict]): + """Callback function initializer""" + + self._f = f + self._kwargs = kwargs if kwargs is not None else dict() + + def __call__(self, coordinates: Optional[OptCoordinates]) -> Any: + """Call the function, if it exists""" + + if self._f is None: + return None + + logger.info("Calling callback function") + return self._f(coordinates, **self._kwargs) + + +def _energy_method_string(species: "Species") -> str: + return "" if species.energy is None else species.energy.method_str + + +def print_geometries_from( + coords_trj: Union[Iterator[OptCoordinates], OptimiserHistory], + species: "Species", + filename: str, +) -> None: + """ + Print geometries from an iterator over a series of coordinates + + Args: + coords_trj: The iterator for coordinates, can be OptimiserHistory + species: The Species for which the coordinate history is generated + filename: Name of file + """ + from autode.species import Species + + assert isinstance(species, Species) + + if not filename.lower().endswith(".xyz"): + filename = filename + ".xyz" + + if os.path.isfile(filename): + logger.warning(f"{filename} already exists, overwriting...") + os.remove(filename) + + # take a copy so that original is not modified + tmp_spc = species.copy() + for coords in coords_trj: + tmp_spc.coordinates = coords.to("cart") + tmp_spc.energy = coords.e + tmp_spc.print_xyz_file(filename=filename, append=True) + + return None diff --git a/autodE/source/autode/opt/optimisers/crfo.py b/autodE/source/autode/opt/optimisers/crfo.py new file mode 100644 index 0000000000000000000000000000000000000000..0d30a768575a4e658778978970fdc2099881b759 --- /dev/null +++ b/autodE/source/autode/opt/optimisers/crfo.py @@ -0,0 +1,266 @@ +""" +Constrained rational function optimisation + +Notation follows: +[1] J. Baker, J. Comput. Chem., 18, 8 1080 +[2] J. Baker, J. Comput. Chem., 13, 240 Ž1992 +""" +import numpy as np +from typing import Union, Optional, List, TYPE_CHECKING + +from autode.log import logger +from autode.values import GradientRMS, Distance +from autode.opt.coordinates import CartesianCoordinates, DICWithConstraints +from autode.opt.coordinates.internals import AnyPIC +from autode.opt.optimisers.rfo import RFOptimiser +from autode.exceptions import OptimiserStepError +from autode.opt.optimisers.hessian_update import ( + BFGSDampedUpdate, + BFGSSR1Update, +) + +if TYPE_CHECKING: + from autode.opt.coordinates.primitives import Primitive + +# max and min bounds for the trust radius +MAX_TRUST = 0.2 +MIN_TRUST = 0.01 + + +class CRFOptimiser(RFOptimiser): + """Constrained optimisation in delocalised internal coordinates""" + + def __init__( + self, + init_trust: float = 0.1, + *args, + extra_prims: Optional[List["Primitive"]] = None, + trust_update: bool = True, + max_move: Union[Distance, float] = Distance(0.12, "ang"), + **kwargs, + ): + """ + Constrained rational function optimisation + + ----------------------------------------------------------------------- + Arguments: + init_alpha: Initial value of the trust radius + + Keyword Args: + extra_prims: A list of aditional coordinates (or constraints) to + add to the DIC optimisation space (optional) + max_move: The maximum distance an atom can move in Cartesian + coordinates in a step (assumed units of Å if not given) + trust_update: Whether to update the trust radius + + See Also: + :py:meth:`RFOptimiser ` + """ + super().__init__(*args, **kwargs) + + if not (MIN_TRUST < init_trust < MAX_TRUST): + init_trust = min(max(init_trust, MIN_TRUST), MAX_TRUST) + logger.warning(f"Setting trust radius to {init_trust:.3f}") + + self.alpha = float(init_trust) + self._trust_update = bool(trust_update) + self._maxmove = Distance(max_move, units="ang") + assert self._maxmove > 0 + self._extra_prims = [] if extra_prims is None else list(extra_prims) + + self._hessian_update_types = [BFGSDampedUpdate, BFGSSR1Update] + + def _log_constrained_opt_progress(self): + """Log information about the constraints""" + n, m = len(self._coords), self._coords.n_constraints + s = self._coords.n_satisfied_constraints + logger.info(f"Optimising {n} coordinates and {m} lagrange multipliers") + + idxs = self._coords.active_indexes + logger.info( + f"Satisfied {s} constraints. Active space" + f" is {len(idxs)} dimensional" + ) + d2l_ev = np.linalg.eigvalsh(self._coords.h[:, idxs][idxs, :]) + logger.info( + f"Hessian in active space has {sum(k < 0 for k in d2l_ev)} " + f"negative eigenvalue(s). Should have {m-s}" + ) + return None + + def _step(self) -> None: + """Partitioned rational function step""" + assert self._coords is not None, "Must have coords to take a step" + + if self.iteration != 0: + self._coords.update_h_from_old_h( + self._history.penultimate, self._hessian_update_types + ) + assert self._coords.h is not None + + self._update_trust_radius() + self._log_constrained_opt_progress() + + # get RFO step + delta_s = self._get_rfo_step() + + # scale back to trust radius only on non-constraint modes + n = len(self._coords) + delta_s_q = delta_s[:n] + if np.linalg.norm(delta_s_q) > self.alpha: + logger.info("Scaling RFO step to trust radius") + delta_s = delta_s * self.alpha / np.linalg.norm(delta_s_q) + + logger.info("Taking an RFO step") + self._take_step_within_max_move(delta_s) + return None + + def _get_rfo_step(self): + """ + Calculate the unscaled RFO step, for the correct set of + coordinates + + Returns: + (np.ndarray): The RFO step + """ + n, m = len(self._coords), self._coords.n_constraints + idxs = self._coords.active_indexes + + # only molec. Hessian should be +ve definite + lmda = self._coords.rfo_shift + hess = self._coords.h - lmda * np.eye(n + m) + # no shift on constraints + for i in range(m): + hess[-m + i, -m + i] = 0.0 + + logger.info(f"Calculated RFO λ = {lmda:.4f}") + # RFO step in active space + hess = hess[:, idxs][idxs, :] + grad = self._coords.g[idxs] + self._check_shifted_hessian_has_correct_struct(hess) + full_step = np.zeros(shape=(n + m)) + rfo_step = -np.matmul(np.linalg.inv(hess), grad) + full_step[idxs] = rfo_step + + return full_step + + def _check_shifted_hessian_has_correct_struct(self, arr) -> None: + """ + Check that the shifted Hessian from RFO or QA has correct + eigenvalue structure + + Args: + arr (np.ndarray): Shifted hessian to check + + Raises: + (OptimiserStepError): if Hessian does not have correct structure + """ + assert self._coords is not None + m = self._coords.n_constraints + o = m - self._coords.n_satisfied_constraints + ev = np.linalg.eigvalsh(arr) + n_negative = sum(k < 0 for k in ev) + if not o == n_negative: + raise OptimiserStepError( + f"Failed to obtain step, shifted Hessian should have {o}" + f" negative eigenvalue(s), but has {n_negative}" + ) + return None + + def _take_step_within_max_move(self, delta_s: np.ndarray): + """ + Take the step by converting internal coordinates to Cartesian + coordinates, and scaling back if the maximum movement of an + atom exceeds max_move + + Arguments: + delta_s (np.ndarray): The step in internal coordinates + """ + assert self._coords is not None + + self._coords.allow_unconverged_back_transform = True + new_coords = self._coords + delta_s + cart_delta = new_coords.to("cart") - self._coords.to("cart") + cart_displ = np.linalg.norm(cart_delta.reshape((-1, 3)), axis=1) + max_displ = np.abs(cart_displ).max() + + self._coords.allow_unconverged_back_transform = False + if max_displ > self._maxmove: + logger.info( + f"Calculated step too large: max. displacement = " + f"{max_displ:.3f} Å, scaling down" + ) + # Note because the transformation is not linear this will not + # generate a step exactly max(∆x) ≡ α, but is empirically close + factor = self._maxmove / max_displ + self._coords = self._coords + (factor * delta_s) + + else: + self._coords = new_coords + + return None + + def _update_trust_radius(self): + """Updates the trust radius before a geometry step""" + assert self._coords is not None, "Must have coordinates!" + + if self.iteration == 0: + return None + + if self._trust_update is False: + return None + + coords_l = self._history.penultimate + pred_delta_e = coords_l.pred_quad_delta_e(self._coords) + trust_ratio = self.last_energy_change / float(pred_delta_e) + last_step_size = np.linalg.norm( + np.array(coords_l) - np.array(self._coords) + ) + + if trust_ratio < 0.25: + self.alpha = max(0.7 * self.alpha, MIN_TRUST) + elif 0.25 < trust_ratio < 0.75: + pass + elif 0.75 < trust_ratio < 1.25: + # increase if step was actually near trust radius + if abs(last_step_size - self.alpha) / self.alpha < 0.05: + self.alpha = min(1.3 * self.alpha, MAX_TRUST) + elif 1.25 < trust_ratio < 1.75: + pass + elif trust_ratio > 1.75: + self.alpha = max(0.7 * self.alpha, MIN_TRUST) + + logger.info( + f"Ratio of actual/predicted dE = {trust_ratio:.3f}," + f" Current trust radius = {self.alpha:.3f}" + ) + + def _initialise_run(self) -> None: + """Initialise the optimisation""" + logger.info("Initialising optimisation") + + self._build_internal_coordinates() + assert self._coords is not None + self._coords.update_h_from_cart_h(self._low_level_cart_hessian) + self._update_gradient_and_energy() + + return None + + def _build_internal_coordinates(self): + """Set the initial coordinates to optimise in, formed using + delocalized internals""" + + if self._species is None: + raise RuntimeError( + "Cannot set initial coordinates. No species set" + ) + + cartesian_coords = CartesianCoordinates(self._species.coordinates) + primitives = AnyPIC.from_species(self._species) + for ic in self._extra_prims: + primitives.add(ic) + + self._coords = DICWithConstraints.from_cartesian( + x=cartesian_coords, primitives=primitives + ) + return None diff --git a/autodE/source/autode/opt/optimisers/dimer.py b/autodE/source/autode/opt/optimisers/dimer.py new file mode 100644 index 0000000000000000000000000000000000000000..c65877855758cef56d643568a32cd0512bf4e12c --- /dev/null +++ b/autodE/source/autode/opt/optimisers/dimer.py @@ -0,0 +1,424 @@ +""" +Dimer method for finding transition states given two points on the PES. +Notation follows +1. https://aip.scitation.org/doi/10.1063/1.2815812 +based on +2. https://aip.scitation.org/doi/10.1063/1.2104507 +3. https://aip.scitation.org/doi/10.1063/1.480097 + +------------------------------------------------------- +x : Cartesian coordinates +g : gradient in cartesian coordinates +""" +import numpy as np + +from typing import Optional, Union, TYPE_CHECKING +from enum import Enum + +from autode.calculations import Calculation +from autode.log import logger +from autode.values import GradientRMS, Angle, MWDistance +from autode.opt.optimisers.base import Optimiser +from autode.opt.coordinates.dimer import DimerCoordinates, DimerPoint + + +if TYPE_CHECKING: + from autode.species.species import Species + from autode.wrappers.methods import Method + + +class Dimer(Optimiser): + """Dimer spanning two points on the PES with a TS at the midpoint""" + + def __init__( + self, + maxiter: int, + coords: DimerCoordinates, + ratio_rot_iters: int = 10, + gtol: Union[float, GradientRMS] = GradientRMS(1e-3, "Ha Å-1"), + trns_tol: MWDistance = MWDistance(1e-3, "Å amu^1/2"), + phi_tol: Angle = Angle(5.0, "°"), + init_alpha: MWDistance = MWDistance(0.3, "Å amu^1/2"), + ): + """ + Dimer optimiser + + ----------------------------------------------------------------------- + Arguments: + maxiter: Maximum number of gradient evaluations to do + + coords: Coordinates of the dimer, consisting of the end points and + the interpolated midpoint + + ratio_rot_iters: Number of rotations per translation in each + dimer step + + gtol: Tolerance on the gradient at the midpoint for convergence + + trns_tol: Tolerance on the minimum root mean square translation + distance, below which convergence is signaled + + phi_tol: Tolerance on the rotation angle below which rotation is + not performed + + init_alpha: Initial step size to use in mass-weighted cartesian + coordinates + """ + super().__init__(maxiter=maxiter, coords=coords) + + self._ratio_rot_iters = ratio_rot_iters + self.gtol = gtol + self.trns_tol = trns_tol + self.phi_tol = phi_tol + self.init_alpha = init_alpha + + self._converged_translation = False # Convergence flag + + assert self._coords is not None + logger.info(f"Initialised a dimer with Δ = {self._coords.delta:.4f} Å") + + @classmethod + def optimise( + cls, + species: "Species", + method: "Method", + n_cores: Optional[int] = None, + coords: Optional[DimerCoordinates] = None, + **kwargs, + ) -> None: + """ + Optimise a dimer pair of coordinates such that the species coordinates + are close to a transition state + + ----------------------------------------------------------------------- + Arguments: + species: Species to optimise to a TS using dimer iterations + + method: Electronic structure method to use to optimise + + n_cores: Number of cores to use for the optimisation + + coords: Dimer coordinates + """ + + if not isinstance(coords, DimerCoordinates): + raise ValueError( + "A dimer optimisation must be initialised from " + "a set of dimer coordinates" + ) + + optimiser = cls(maxiter=100, coords=coords) + optimiser.run(species=species, method=method, n_cores=n_cores) + return None + + def _step(self) -> None: + """ + Do a single dimer optimisation step, consisting of several rotation and + translation steps. + """ + self._update_gradient_at(DimerPoint.left) + + self._optimise_rotation() + trns_result = self._translate() + + if trns_result == _StepResult.skipped_translation: + self._converged_translation = True + + return None + + def _rotate(self) -> "_StepResult": + """Apply a rotation""" + assert self._coords is not None + + c_phi0, dc_dphi0 = self._c, self._dc_dphi + logger.info( + "Doing a single dimer rotation to minimise the curvature. " + f"Current: C = {c_phi0:.4f} " + f"and dC/dϕ = {dc_dphi0:.4f}" + ) + + cached_coordinates = self._coords.copy() + + phi_1 = self._phi1.to("radians") + logger.info( + f'Rotating by ϕ = {phi_1.to("degrees"):.4f}º and ' + f"evaluating the curvature" + ) + + if abs(phi_1) < self.phi_tol: + logger.info("Rotation angle was below the threshold, not rotating") + return _StepResult.skipped_rotation + + self._rotate_coords(phi_1, update_g1=True) + + b1 = 0.5 * dc_dphi0 # eqn. 8 from ref. [1] + + a1 = ( + c_phi0 - self._c + b1 * np.sin(2 * phi_1) + ) / ( # eqn. 9 from ref. [1] + 1 - 2.0 * np.cos(2.0 * phi_1) + ) + + a0 = 2.0 * (c_phi0 - a1) # eqn. 10 from ref. [1] + phi_min = Angle(0.5 * np.arctan(b1 / a1), units="radians") + logger.info(f'ϕ_min = {phi_min.to("degrees"):.4f}º') + + c_min = ( + 0.5 * a0 + a1 * np.cos(2.0 * phi_min) + b1 * np.sin(2.0 * phi_min) + ) + + if c_min > c_phi0: + logger.info( + "Optimised curvature was larger than the initial, " + "adding π/2" + ) + phi_min += np.pi / 2.0 + + # Rotate back from the test point, then to the minimum + self._coords = cached_coordinates + self._rotate_coords(phi=phi_min, update_g1=True) + + return _StepResult.did_rotation + + def _translate(self, update_g0=True) -> "_StepResult": + """Translate the dimer under the translational force""" + assert self._coords is not None + x0 = self._coords.x0 + + trns_iters = [c for c in self._history if c.did_translation] + + if len(trns_iters) < 2: + step_size = float(self.init_alpha) + logger.info( + f"Did not have two previous translation step, guessing" + f" α = {step_size} Å" + ) + + else: + prev_trns_iter = trns_iters[-2] + logger.info( + f"Did {len(trns_iters)} previous translations, can " + f"calculate α using the Barzilai–Borwein method" + ) + + step_size = ( + np.abs( + np.dot( + (x0 - prev_trns_iter.x0), + (self._coords.f_t - prev_trns_iter.f_t), + ) + ) + / np.linalg.norm(self._coords.f_t - prev_trns_iter.f_t) ** 2 + ) + + delta_x = step_size * self._coords.f_t + trns_rms = MWDistance(np.sqrt(np.mean(np.square(delta_x)))) + + if trns_rms < self.trns_tol: + logger.info(f"Step length small than tolerance {self.trns_tol}") + return _StepResult.skipped_translation + + logger.info(f"Translating by ~{trns_rms:.4f} per coordinate") + + coords = self._coords.copy() + coords += delta_x + + self._coords = coords + self._coords.phi = Angle(0.0) # Did not rotation + self._coords.dist = trns_rms # but did translate + + if update_g0: + self._update_gradient_at(DimerPoint.midpoint) + + return _StepResult.did_translation + + def _initialise_run(self) -> None: + """Initialise running the dimer optimisation""" + assert self._coords is not None and self._species is not None + + if np.isclose(self._coords.delta, 0.0): + raise RuntimeError("Zero distance between the dimer points") + + self._coords._g = np.zeros(shape=(3, 3 * self._species.n_atoms)) + + # TODO: Hessian. Ref [1] shows that a BFGS step to the translation + # and rotation -> faster convergence than SD steps + + self._update_gradient_at(DimerPoint.midpoint) + self._update_gradient_at(DimerPoint.left) + + return None + + @property + def converged(self) -> bool: + """Has the dimer converged?""" + assert self._coords is not None + + if self._converged_translation: + logger.info( + "Converged purely based on translation of the " + "dimer midpoint" + ) + return True + + rms_g0 = np.sqrt(np.mean(np.square(self._coords.g0))) + return self.iteration > 0 and rms_g0 < self.gtol + + def _update_gradient_and_energy(self) -> None: + """Update the gradient at the midpoint""" + return self._update_gradient_at(DimerPoint.midpoint) + + def _update_gradient_at(self, point: DimerPoint) -> None: + """Update the gradient at one of the points in the dimer""" + assert ( + self._coords is not None + and self._species is not None + and self._method is not None + and self._method.keywords.grad is not None + ) + i = int(point) + + self._species.coordinates = self._coords.x_at( + point, mass_weighted=False + ) + + calc = Calculation( + name=f"{self._species.name}_{i}_{self.iteration}", + molecule=self._species, + method=self._method, + keywords=self._method.keywords.grad, + n_cores=self._n_cores, + ) + calc.run() + assert ( + self._species.energy is not None + and self._species.gradient is not None + ) + + self._coords.e = self._species.energy + self._coords.set_g_at( + point, self._species.gradient.flatten(), mass_weighted=False + ) + + calc.clean_up(force=True, everything=True) + return None + + @property + def _theta(self) -> np.ndarray: + """Optimisation direction""" + return self._theta_steepest_descent + + @property + def _theta_steepest_descent(self) -> np.ndarray: + """Rotation direction Θ, calculated using steepest descent""" + assert self._coords is not None + + f_r = self._coords.f_r + # F_R / |F_R| with a small jitter to prevent division by zero + return f_r / (np.linalg.norm(f_r) + 1e-8) + + @property + def _c(self) -> float: + """Curvature of the PES, C_τ. eqn. 4 in ref [1]""" + assert self._coords is not None + + g1, g0 = self._coords.g1, self._coords.g0 + return np.dot((g1 - g0), self._coords.tau_hat) / self._coords.delta + + @property + def _dc_dphi(self) -> float: + """dC_τ/dϕ eqn. 6 in ref [1]""" + assert self._coords is not None + + g1, g0 = self._coords.g1, self._coords.g0 + + return 2.0 * np.dot((g1 - g0), self._theta) / self._coords.delta + + @property + def _phi1(self) -> Angle: + """φ_1. eqn 5 in ref [1]""" + val = -0.5 * np.arctan(self._dc_dphi / (2.0 * np.linalg.norm(self._c))) + return Angle(val, "radians") + + def _rotate_coords(self, phi: Angle, update_g1: bool = True) -> None: + """ + Rotate the dimer by an angle phi around the midpoint. + eqn. 13 in ref. [2] + + Arguments: + phi (float): Rotation angle in radians (ϕ) + + update_g1 (bool): Update the gradient on point 1 after the rotation + """ + assert self._coords is not None + + x0 = self._coords.x0.copy() # Midpoint coordinates + g0 = self._coords.g0.copy() # Midpoint gradient + + delta = self._coords.delta * ( + self._coords.tau_hat * np.cos(phi.to("rad")) + + self._theta * np.sin(phi.to("rad")) + ) + + max_step_c = np.max(np.abs(self._coords.x1.copy() - (x0 + delta))) + + if max_step_c > self.init_alpha: + logger.warning( + f"Step size ({max_step_c}) was above the tolerance" + f" {self.init_alpha} Å amu^1/2. Scaling down" + ) + return self._rotate_coords(phi=Angle(phi / 2), update_g1=update_g1) + + self._coords = self._coords.copy() + self._coords.x1 = x0 + delta + self._coords.x2 = x0 - delta + + self._coords.dist = MWDistance(0.0) + self._coords.phi = phi + + # Midpoint has not moved so it's gradient its retained + self._coords.g0 = g0 + + # But both the end points have, so clear their gradients + self._coords.g1[:] = self._coords.g2[:] = np.nan + + if update_g1: + self._update_gradient_at(DimerPoint.left) + + logger.info( + f"Rotated coordinates, now have |g1 - g0| = " + f"{np.linalg.norm(self._coords.g1 - self._coords.g0):.4f}." + f" ∆ = {self._coords.delta:.3f}" + ) + return None + + def _optimise_rotation(self): + """Rotate the dimer optimally""" + logger.info( + f"Minimising dimer rotation up to " + f'δϕ = {self.phi_tol.to("degrees"):.4f}º' + ) + + for i in range(self._ratio_rot_iters): + result = self._rotate() + + if ( + result == _StepResult.skipped_rotation + or abs(self._coords.phi) < self.phi_tol + ): + break + + logger.info( + f"Micro iteration: {i}." + f' ϕ={self._coords.phi.to("degrees"):.2f}º' + ) + + return None + + +class _StepResult(Enum): + did_rotation = 0 + skipped_rotation = 1 + + did_translation = 2 + skipped_translation = 3 diff --git a/autodE/source/autode/opt/optimisers/hessian_update.py b/autodE/source/autode/opt/optimisers/hessian_update.py new file mode 100644 index 0000000000000000000000000000000000000000..4bf786497fa302f04cb41309e7f976499d420584 --- /dev/null +++ b/autodE/source/autode/opt/optimisers/hessian_update.py @@ -0,0 +1,580 @@ +import numpy as np + +from abc import ABC, abstractmethod +from autode.log import logger + + +class HessianUpdater(ABC): + """Update strategy for the (inverse) Hessian matrix""" + + def __init__(self, **kwargs): + r""" + Hessian updater + + ---------------------------------------------------------------------- + Keyword Arguments: + h (np.ndarray): Hessian (:math:`H`), shape = (N, N) + + h_inv (np.ndarray): Inverse Hessian (:math:`H^{-1}`), shape = (N, N) + + s (np.ndarray): Coordinate shift. :math:`s = R_{i+1} - R_i` + + y (np.ndarray): Gradient shift. + :math:`y = \nabla E_{i+1} - \nabla E_i` + + subspace_idxs (list(int)): Indexes of the components of the + hessian to update + """ + + self.h = kwargs.get("h", None) + self.h_inv = kwargs.get("h_inv", None) + self._h_init, self._h_inv_init = None, None + + self.s = kwargs.get("s", None) + self.y = kwargs.get("y", None) + self.subspace_idxs = kwargs.get("subspace_idxs", None) + self._apply_subspace() + + def _apply_subspace(self) -> None: + """ + Reduce the step, gradient difference vectors and the Hessian & inverse + to include only a subset of the total elements + """ + idxs = self.subspace_idxs + + if idxs is None: + return # Cannot apply with no defined indexes + + if len(idxs) == 0: + raise ValueError( + "Cannot reduce s, y, h to 0 dimensional. " + "idxs must have at least one element" + ) + + logger.info(f"Updated hessian will have shape {len(idxs)}x{len(idxs)}") + + for attr in ("h", "h_inv"): + m = getattr(self, attr) + setattr(self, f"_{attr}_init", None if m is None else m.copy()) + setattr(self, attr, None if m is None else m[:, idxs][idxs, :]) + + for attr in ("s", "y"): + v = getattr(self, attr) + setattr(self, attr, None if v is None else v[idxs]) + + return None + + def _matrix_in_full_space( + self, m: np.ndarray, m_sub: np.ndarray + ) -> np.ndarray: + """ + Create a Hessian in the full initial space i.e. having only updated + the components present in self.subspace_idxs. Also ensures that the + Hessian is Hermitian + """ + assert self.subspace_idxs is not None + + for i, idx_i in enumerate(self.subspace_idxs): + for j, idx_j in enumerate(self.subspace_idxs): + m[idx_i, idx_j] = m_sub[i, j] + + return _ensure_hermitian(m) + + @property + def updated_h_inv(self) -> np.ndarray: + """ + Calculate H^{-1} from a previous inverse Hessian, coordinate shift and + gradient shift + + ----------------------------------------------------------------------- + Returns: + (np.ndarray): :math:`H^{-1}` + + Raises: + (RuntimeError): If the update fails + """ + + if self.h_inv is None: + raise RuntimeError("Cannot update H^-1, no inverse defined") + + if self._h_inv_init is None: + return self._updated_h_inv + + return self._matrix_in_full_space( + self._h_inv_init, self._updated_h_inv + ) + + @property + def updated_h(self) -> np.ndarray: + """ + Calculate H from a previous Hessian, coordinate shift and gradient + shift + + ----------------------------------------------------------------------- + Returns: + (np.ndarray): :math:`H` + + Raises: + (RuntimeError): If the update fails + """ + + if self.h is None: + raise RuntimeError("Cannot update H, no Hessian defined") + + if self._h_init is None: + return self._updated_h + + return self._matrix_in_full_space(self._h_init, self._updated_h) + + @property + @abstractmethod + def conditions_met(self) -> bool: + """Are the conditions met to update the Hessian with this method?""" + + @property + @abstractmethod + def _updated_h(self) -> np.ndarray: + """Calculate H""" + + @property + @abstractmethod + def _updated_h_inv(self) -> np.ndarray: + """Calculate H^{-1}""" + + def __str__(self): + return self.__repr__() + + +class BFGSUpdate(HessianUpdater): + def __repr__(self): + return "BFGS" + + @property + def _updated_h(self) -> np.ndarray: + r""" + Update the Hessian with a BFGS like update + + .. math:: + + H_{new} = H + \frac{y y^T}{y^T s} - \frac{H s s^T H} + {s^T H s} + + + ----------------------------------------------------------------------- + See Also: + :py:meth:`BFGSUpdate._updated_h_inv ` + """ + h_s = np.matmul(self.h, self.s) + + h_new = ( + self.h + + np.outer(self.y, self.y) / np.dot(self.y, self.s) + - ( + np.outer(h_s, np.matmul(self.s.T, self.h)) + / np.dot(self.s, h_s) + ) + ) + + return h_new + + @property + def _updated_h_inv(self): + r""" + Sherman–Morrison inverse matrix update + + .. math:: + + H_{new}^{-1} = H^{-1} + + \frac{(s^Ty + y^T H^{-1} y) s^T s} + {s^T y} - + \frac{H^{-1} y s^T + s y^T H^{-1}} + {s^T y} + + where :math:`s = x_{l} - x_{l-1},\; \boldsymbol{y} = + \nabla E_l - \nabla E_{l-1}`. + + ----------------------------------------------------------------------- + See Also: + :py:meth:`BFGSUpdate._updated_h ` + """ + logger.info("Calculating H^(-1) with Sherman–Morrison formula") + + s_y = np.dot(self.s, self.y) + y_h_inv_y = np.dot(self.y, np.matmul(self.h_inv, self.y)) + s_s = np.outer(self.s, self.s) + h_inv_y_s = np.matmul(self.h_inv, np.outer(self.y, self.s)) + s_y_h_inv = np.outer(self.s, np.matmul(self.y, self.h_inv)) + + h_inv_l = ( + self.h_inv + + (s_y + y_h_inv_y) / (s_y**2) * s_s + - (h_inv_y_s + s_y_h_inv) / s_y + ) + + return h_inv_l + + @property + def conditions_met(self) -> bool: + """BFGS update must meet the secant condition""" + + if np.dot(self.y, self.s) < 0: + logger.warning("Secant condition not satisfied. Skipping H update") + return False + + return True + + +class BFGSPDUpdate(BFGSUpdate): + """BFGS update while ensuring positive definiteness""" + + def __init__(self, min_eigenvalue: float = 1e-5, **kwargs): + super().__init__(**kwargs) + + self.min_eigenvalue = min_eigenvalue + + def __repr__(self): + return "BFGS positive definite" + + @property + def conditions_met(self) -> bool: + """Are all the conditions met to update the Hessian""" + + eigvals = np.linalg.eigvals(self._updated_h) + return super().conditions_met and np.all(eigvals > self.min_eigenvalue) + + +class BFGSDampedUpdate(BFGSPDUpdate): + """ + Powell damped BFGS update that ensures reasonable conditioning with the + 'positive definite' conditions still imposed + """ + + @property + def _updated_h(self) -> np.ndarray: + """ + Powell damped BFGS from: Math. Prog. Comp. (2016) 8:435–459 + (10.1007/s12532-016-0101-2) + """ + + h, s, y = self.h, self.s, self.y + shs = np.linalg.multi_dot((s.T, h, s)) + + if s.dot(y) < 0.2 * shs: + theta = (0.8 * shs) / (shs - s.dot(y)) + else: + theta = 1.0 + + y_ = theta * y - (1.0 - theta) * h.dot(s) + + h_new = ( + h + - (np.outer(h.dot(s), np.matmul(s.T, h)) / shs) + + np.outer(y_, y_) / np.dot(y_, s) + ) + + return h_new + + +class SR1Update(HessianUpdater): + def __repr__(self): + return "SR1" + + @property + def _updated_h(self) -> np.ndarray: + r""" + Update H using a symmetric-rank 1 (SR1) update + + .. math:: + H_{new} = H + \frac{(y- Hs)(y - Hs)^T} + {(y- Hs)^T s} + """ + + y_hs = self.y - np.matmul(self.h, self.s) + h_new = self.h + np.outer(y_hs, y_hs) / np.dot(y_hs, self.s) + + return h_new + + @property + def _updated_h_inv(self) -> np.ndarray: + r""" + Update H^-1 using a symmetric-rank 1 (SR1) update + + .. math:: + + H_{new}^{-1} = H^{-1} + \frac{(s- H^{-1}y)(s - H^{-1}y)^T} + {(s- Hy)^T y} + + """ + + s_h_inv_y = self.s - np.matmul(self.h_inv, self.y) + h_inv_new = self.h_inv + ( + np.outer(s_h_inv_y, s_h_inv_y) / np.dot(s_h_inv_y, self.y) + ) + + return h_inv_new + + @property + def conditions_met(self) -> bool: + r""" + Condition for SR1 update. See: + https://en.wikipedia.org/wiki/Symmetric_rank-one + + .. math:: + + |s (y - Hs)| \ge r ||s|| \cdot ||y - Hs|| + + where :math:`r \in (0, 1)` = 1E-8. + """ + r = 1e-8 + + if self.h_inv is not None and self.h is None: + logger.warning( + "SR1 requires Hessian to determine conditions, " + "calculating H from H^(-1)" + ) + self.h = np.linalg.inv(self.h_inv) + + y_hs = self.y - np.matmul(self.h, self.s) + s_yhs = np.dot(self.s, y_hs) + norm_s, norm_yhs = np.linalg.norm(self.s), np.linalg.norm(y_hs) + + return np.abs(s_yhs) > r * norm_s * norm_yhs + + +class NullUpdate(HessianUpdater): + def __repr__(self): + return "Null" + + @property + def conditions_met(self) -> bool: + """Conditions are always met for a null optimiser""" + return True + + @property + def _updated_h(self) -> np.ndarray: + """Updated H is just the input Hessian""" + return self.h.copy() + + @property + def _updated_h_inv(self) -> np.ndarray: + """Updated inverse H is just the input inverse Hessian""" + return self.h_inv.copy() + + +class BofillUpdate(HessianUpdater): + """ + Hessian update strategy suggested by Bofill[2] with notation taken from + ref. [1]. + + [1] V. Bakken, T. Helgaker, JCP, 117, 9160, 2002 + [2] J. M. Bofill, J. Comput. Chem., 15, 1, 1994 + """ + + # Threshold on |Δg - HΔx| below which the Hessian will not be updated, to + # prevent dividing by zero + min_update_tol = 1e-6 + + def __repr__(self): + return "Bofill" + + @property + def _updated_h(self) -> np.ndarray: + r""" + Bofill Hessian update, interpolating between MS and PBS update + strategies. Follows ref. [1] where the notation is + + .. math:: + h = \boldsymbol{G}_{i-1} + + y = \Delta\boldsymbol{g} = \boldsymbol{g}_i - \boldsymbol{g}_{i-1} + + s = \Delta\boldsymbol{x} = \boldsymbol{x}_i - \boldsymbol{x}_{i-1} + + ----------------------------------------------------------------------- + Returns: + (np.ndarray): H + """ + logger.info("Updating the Hessian with the Bofill scheme") + + # from ref. [1] the approximate Hessian (G) is self.H + G_i_1 = self.h # G_{i-1} + dE_i = self.y - np.dot(G_i_1, self.s) # ΔE_i = Δg_i - G_{i-1}Δx_i + + if np.linalg.norm(dE_i) < self.min_update_tol: + logger.warning( + f"|Δg_i - G_i-1Δx_i| < {self.min_update_tol:.4f} " + f"not updating the Hessian" + ) + return self.h.copy() + + # G_i^MS eqn. 42 from ref. [1] + G_i_MS = G_i_1 + np.outer(dE_i, dE_i) / np.dot(dE_i, self.s) + + # G_i^PBS eqn. 43 from ref. [1] + dxTdg = np.dot(self.s, self.y) + G_i_PSB = ( + G_i_1 + + ( + (np.outer(dE_i, self.s) + np.outer(self.s, dE_i)) + / np.dot(self.s, self.s) + ) + - ( + ( + (dxTdg - np.linalg.multi_dot((self.s, G_i_1, self.s))) + * np.outer(self.s, self.s) + ) + / np.dot(self.s, self.s) ** 2 + ) + ) + + # ϕ from eqn. 46 from ref [1] + phi_bofill = 1.0 - ( + np.dot(self.s, dE_i) ** 2 + / (np.dot(self.s, self.s) * np.dot(dE_i, dE_i)) + ) + + logger.info(f"ϕ_Bofill = {phi_bofill:.6f}") + + return (1.0 - phi_bofill) * G_i_MS + phi_bofill * G_i_PSB + + @property + def _updated_h_inv(self) -> np.ndarray: + """Updated inverse Hessian is available only from the updated H""" + return np.linalg.inv(self._updated_h) + + @property + def conditions_met(self) -> bool: + """ + No conditions are need to be satisfied to perform a Bofill update, + apart from that on the shapes of the vectors + """ + return True + + +class FlowchartUpdate(HessianUpdater): + """ + A hybrid update scheme combining BFGS, SR1 and PSB Hessian update + formulae. Proposed in A. B. Birkholz and H. B. Schlegel in + Theor. Chem. Acc., 135 (84), 2016. This implementation is slightly + modified. + """ + + def __repr__(self): + return "Flowchart" + + @property + def _updated_h(self) -> np.ndarray: + """ + Flowchart (or FlowPSB) Hessian update scheme, that dynamically + switches between BFGS and SR1 depending on some criteria. + + Alternatively switches to PSB update as a fallback if none of + the criteria are satisfied. Notation follows A. B. Birkholz, + H. B. Schlegel, Theor. Chem. Acc., 135 (84), 2016. + + Returns: + (np.ndarray): H + """ + z = self.y - np.matmul(self.h, self.s) + sr1_criteria = np.dot(z, self.s) / ( + np.linalg.norm(z) * np.linalg.norm(self.s) + ) + bfgs_criteria = np.dot(self.y, self.s) / ( + np.linalg.norm(self.y) * np.linalg.norm(self.s) + ) + if sr1_criteria < -0.1: + h_new = self.h + np.outer(z, z) / np.dot(z, self.s) + return h_new + elif bfgs_criteria > 0.1: + bfgs_delta_h = np.outer(self.y, self.y) / np.dot(self.y, self.s) + bfgs_delta_h -= np.linalg.multi_dot( + (self.h, self.s.reshape(-1, 1), self.s.reshape(1, -1), self.h) + ) / np.linalg.multi_dot( + (self.s.flatten(), self.h, self.s.flatten()) + ) + h_new = self.h + bfgs_delta_h + return h_new + else: + # Notation copied from Bofill update + G_i_1 = self.h # G_{i-1} + dE_i = self.y - np.dot(G_i_1, self.s) # ΔE_i = Δg_i - G_{i-1}Δx_i + dxTdg = np.dot(self.s, self.y) + G_i_PSB = ( + G_i_1 + + ( + (np.outer(dE_i, self.s) + np.outer(self.s, dE_i)) + / np.dot(self.s, self.s) + ) + - ( + ( + (dxTdg - np.linalg.multi_dot((self.s, G_i_1, self.s))) + * np.outer(self.s, self.s) + ) + / np.dot(self.s, self.s) ** 2 + ) + ) + return G_i_PSB + + @property + def _updated_h_inv(self) -> np.ndarray: + """Flowchart update is only available for Hessian""" + return np.linalg.inv(self._updated_h) + + @property + def conditions_met(self) -> bool: + """ + Flowchart update does not have any conditions, as + update scheme is dynamically selected + """ + return True + + +class BFGSSR1Update(HessianUpdater): + """ + Interpolates between BFGS and SR1 update in a fashion similar + to Bofill updates, but suited for minimisations. Proposed by + Farkas and Schlegel in J. Chem. Phys., 111, 1999, 10806 + """ + + def __repr__(self): + return "BFGS-SR1" + + @property + def _updated_h(self) -> np.ndarray: + """ + Hybrid BFGS and SR1 update. The mixing parameter phi is defined + as the square root of the (1 - phi_Bofill) used in Bofill update. + + Returns: + (np.ndarray): The updated hessian + """ + bfgs_delta_h = np.outer(self.y, self.y) / np.dot(self.y, self.s) + bfgs_delta_h -= np.linalg.multi_dot( + (self.h, self.s.reshape(-1, 1), self.s.reshape(1, -1), self.h) + ) / np.linalg.multi_dot((self.s.flatten(), self.h, self.s.flatten())) + + y_hs = self.y - np.matmul(self.h, self.s) + sr1_delta_h = np.outer(y_hs, y_hs) / np.dot(y_hs, self.s) + + # definition according to Farkas, Schlegel, J Chem. Phys., 111, 1999 + # NOTE: this phi is (1 - original_phi_bofill) + phi = np.dot(self.s, y_hs) ** 2 / ( + np.dot(self.s, self.s) * np.dot(y_hs, y_hs) + ) + sqrt_phi = np.sqrt(phi) + logger.info(f"BFGS-SR1 update: ϕ = {sqrt_phi:.4f}") + return self.h + sqrt_phi * sr1_delta_h + (1 - sqrt_phi) * bfgs_delta_h + + @property + def _updated_h_inv(self) -> np.ndarray: + """For BFGS-SR1 update, only hessian is available""" + return np.linalg.inv(self._updated_h) + + @property + def conditions_met(self) -> bool: + """No conditions need to be satisfied for BFGS-SR1 update""" + return True + + +def _ensure_hermitian(matrix: np.ndarray) -> np.ndarray: + return (matrix + matrix.T) / 2.0 diff --git a/autodE/source/autode/opt/optimisers/prfo.py b/autodE/source/autode/opt/optimisers/prfo.py new file mode 100644 index 0000000000000000000000000000000000000000..6a5a741945edc245b87d11aa4ece2da9181bb2ea --- /dev/null +++ b/autodE/source/autode/opt/optimisers/prfo.py @@ -0,0 +1,143 @@ +"""Partitioned rational function optimisation""" +import numpy as np +from typing import Union, Optional + +from autode.log import logger +from autode.values import Distance +from autode.opt.optimisers.crfo import CRFOptimiser +from autode.opt.optimisers.hessian_update import BofillUpdate +from autode.opt.coordinates.cartesian import CartesianCoordinates + + +class PRFOptimiser(CRFOptimiser): + def __init__( + self, + init_alpha: Union[Distance, float] = 0.05, + recalc_hessian_every: int = 10, + imag_mode_idx: int = 0, + *args, + **kwargs, + ): + """ + Partitioned rational function optimiser (PRFO) using a maximum step + size of alpha trying to maximise along a mode while minimising along + all others to locate a transition state (TS) + + ----------------------------------------------------------------------- + + Arguments: + init_alpha: Maximum step size (default Å if unit not given) + + imag_mode_idx: Index of the imaginary mode to follow. Default + is 0th mode i.e. the most negative mode + + See Also: + :py:meth:`RFOOptimiser ` + """ + super().__init__(*args, **kwargs) + + self.alpha = Distance(init_alpha, units="ang") + assert self.alpha > 0 + self.recalc_hessian_every = int(recalc_hessian_every) + self._mode_idx = imag_mode_idx + self._last_eigvec: Optional[np.ndarray] = None # store last mode + self._hessian_update_types = [BofillUpdate] + + def _step(self) -> None: + """Partitioned rational function step""" + assert self._coords is not None and self._coords.g is not None + + if self.should_calculate_hessian: + self._update_hessian() + elif self.iteration != 0: + self._coords.update_h_from_old_h( + self._history.penultimate, self._hessian_update_types + ) + + assert self._coords.h is not None # must set .h + + b, u = np.linalg.eigh(self._coords.h) + f = u.T.dot(self._coords.g) + n_negative_eigenvalues = sum(lmda < 0 for lmda in b) + logger.info( + f"∇^2E has {n_negative_eigenvalues} negative " + f"eigenvalue(s). Should have 1" + ) + + imag_idx = self._get_imag_mode_idx(u) + logger.info(f"Following mode {imag_idx} uphill") + + b_max = b[imag_idx] + u_max = u[:, imag_idx] + f_max = f[imag_idx] + + b_min = np.delete(b, imag_idx) + u_min = np.delete(u, imag_idx, axis=1) + f_min = np.delete(f, imag_idx) + + n = len(b) + delta_s = np.zeros(shape=(n,)) + # downhill step + aug_h_min = np.zeros(shape=(n, n)) + aug_h_min[: n - 1, : n - 1] = np.diag(b_min) + aug_h_min[:-1, -1] = aug_h_min[-1, :-1] = f_min + lambda_n = np.linalg.eigvalsh(aug_h_min)[0] + logger.info(f"Calculated λ_n = {lambda_n:.6f}") + + for i in range(n - 1): + delta_s -= f_min[i] * u_min[:, i] / (b_min[i] - lambda_n) + + # uphill step + aug_h_max = np.zeros(shape=(2, 2)) + aug_h_max[:1, :1] = b_max + aug_h_max[:-1, -1] = aug_h_max[-1, :-1] = f_max + lambda_p = np.linalg.eigvalsh(aug_h_max)[-1] + logger.info(f"Calculated λ_p = {lambda_p:.6f}") + + delta_s -= f_max * u_max / (b_max - lambda_p) + + self._last_eigvec = u[:, imag_idx].flatten() + self._take_step_within_trust_radius(delta_s) + return None + + def _get_imag_mode_idx(self, u: np.ndarray): + """ + Find the imaginary mode to follow upwards in the current step. + + Args: + u (np.ndarray): The Hessian eigenvectors + + Returns: + (int): Integer + """ + if self.iteration == 0: + return self._mode_idx + + overlaps = [] + for i in range(u.shape[1]): + overlaps.append( + np.abs(np.dot(u[:, i].flatten(), self._last_eigvec)) + ) + + mode_idx = np.argmax(overlaps) + logger.info(f"Overlap with previous TS mode: {overlaps[mode_idx]:.3f}") + return mode_idx + + def _initialise_run(self) -> None: + """ + Initialise running a partitioned rational function optimisation by + setting the coordinates and Hessian + """ + assert self._species is not None, "Must have a species to init" + + self._coords = CartesianCoordinates(self._species.coordinates).to( + "dic" + ) + self._update_hessian_gradient_and_energy() + return None + + @property + def should_calculate_hessian(self) -> bool: + """Should an explicit Hessian calculation be performed?""" + n = self.iteration + return n > 1 and n % self.recalc_hessian_every == 0 diff --git a/autodE/source/autode/opt/optimisers/qa.py b/autodE/source/autode/opt/optimisers/qa.py new file mode 100644 index 0000000000000000000000000000000000000000..c1994b92296a9bb2d8ae97b07450127274fabbcb --- /dev/null +++ b/autodE/source/autode/opt/optimisers/qa.py @@ -0,0 +1,122 @@ +""" +Constrained optimisation with quadratic trust radius model + +Also known as Quadratic Approximation (QA) or Trust-Radius Model (TRM) + +References: +[1] P. Culot et al. Theor. Chim. Acta, 82, 1992, 189-205 +[2] T. Helgaker, Chem. Phys. Lett., 182(5), 1991, 503-510 +[3] J. T. Golab et al. Chem. Phys., 78, 1983, 175-199 +[4] R. Fletcher, Practical Methods of Optimization, Wiley, Chichester, 1981 +""" +import numpy as np +from scipy.optimize import root_scalar + +from autode.log import logger +from autode.opt.optimisers.crfo import CRFOptimiser +from autode.exceptions import OptimiserStepError + + +class QAOptimiser(CRFOptimiser): + """Quadratic trust-radius optimiser in delocalised internal coordinates""" + + def _step(self) -> None: + """Trust radius step""" + assert self._coords is not None, "Must have coords to take a step" + + if self.iteration != 0: + self._coords.update_h_from_old_h( + self._history.penultimate, self._hessian_update_types + ) + assert self._coords.h is not None + + self._update_trust_radius() + self._log_constrained_opt_progress() + + n = len(self._coords) + + # Take RFO step if within trust radius + delta_s_rfo = self._get_rfo_step() + if np.linalg.norm(delta_s_rfo[:n]) <= self.alpha: + logger.info("Taking an RFO step") + self._take_step_within_max_move(delta_s_rfo) + return None + + # otherwise use QA step within trust + try: + delta_s_qa = self._get_qa_step() + logger.info("Taking a QA step within trust radius") + self._take_step_within_max_move(delta_s_qa) + return None + + # if QA fails, used scaled RFO step + except OptimiserStepError as exc: + logger.info(f"QA step failed: {str(exc)}, using scaled RFO step") + factor = self.alpha / np.linalg.norm(delta_s_rfo[:n]) + self._take_step_within_max_move(delta_s_rfo * factor) + return None + + def _get_qa_step(self): + """ + Calculate the QA step within trust radius for the current + set of coordinates + + Returns: + (np.ndarray): The trust radius step + """ + n, m = len(self._coords), self._coords.n_constraints + idxs = self._coords.active_indexes + + def shifted_newton_step(hess, grad, lmda, check=False): + """ + Level-shifted Newton step (H-λI)^-1 . g + optional check of Hessian eigenvalue structure + """ + hess = hess - lmda * np.eye(hess.shape[0]) + # no shift on constraints + for i in range(m): + hess[-m + i, -m + i] = 0.0 + full_step = np.zeros_like(grad) + hess = hess[:, idxs][idxs, :] + grad = grad[idxs] + if check: + self._check_shifted_hessian_has_correct_struct(hess) + qa_step = -np.matmul(np.linalg.inv(hess), grad) + full_step[idxs] = qa_step + return full_step + + def qa_step_error(lmda): + """Error in step size""" + ds = shifted_newton_step(self._coords.h, self._coords.g, lmda) + ds_atoms = ds[:n] + return np.linalg.norm(ds_atoms) - self.alpha + + # if molar Hessian +ve definite & step within trust use simple qN + min_b = self._coords.min_eigval + if min_b > 0 and qa_step_error(0.0) <= 0.0: + return shifted_newton_step( + self._coords.h, self._coords.g, 0.0, True + ) + + # Find λ in range (-inf, b) + for k in range(500): + right_bound = min_b - 0.5**k + if qa_step_error(right_bound) > 0: + break + assert qa_step_error(right_bound) > 0 + + for k in range(-6, 10): + left_bound = right_bound - 2**k + if qa_step_error(left_bound) < 0: + break + if not qa_step_error(left_bound) < 0: + raise OptimiserStepError("Unable to find bounds for root search") + + res = root_scalar(f=qa_step_error, bracket=[left_bound, right_bound]) + if (not res.converged) or (res.root >= min_b): + raise OptimiserStepError("QA root search failed") + + logger.info(f"Calculated QA λ = {res.root:.4f}") + return shifted_newton_step( + self._coords.h, self._coords.g, res.root, True + ) diff --git a/autodE/source/autode/opt/optimisers/rfo.py b/autodE/source/autode/opt/optimisers/rfo.py new file mode 100644 index 0000000000000000000000000000000000000000..25bae009b7dc1ff520e5e7d3f0c1e04ec809218d --- /dev/null +++ b/autodE/source/autode/opt/optimisers/rfo.py @@ -0,0 +1,148 @@ +import numpy as np +from typing import TYPE_CHECKING, Union + +from autode.log import logger +from autode.utils import work_in_tmp_dir +from autode.opt.optimisers.base import NDOptimiser +from autode.opt.coordinates import CartesianCoordinates +from autode.values import Distance +from autode.opt.optimisers.hessian_update import BFGSPDUpdate, NullUpdate + +if TYPE_CHECKING: + from autode.hessians import Hessian + + +class RFOptimiser(NDOptimiser): + """Rational function optimisation in delocalised internal coordinates""" + + def __init__( + self, *args, init_alpha: Union[Distance, float] = 0.1, **kwargs + ): + """ + Rational function optimiser (RFO) using a maximum step size of alpha + + ----------------------------------------------------------------------- + Arguments: + init_alpha: Maximum step size, which controls the maximum component + of the step. If units not given, Angstrom assumed. + + args: Additional arguments for ``NDOptimiser`` + + kwargs: Additional keywords arguments for ``NDOptimiser`` + + See Also: + :py:meth:`NDOptimiser ` + """ + super().__init__(*args, **kwargs) + + self.alpha = float(Distance(init_alpha, units="ang")) + assert self.alpha > 0 + self._hessian_update_types = [BFGSPDUpdate, NullUpdate] + + def _step(self) -> None: + """RFO step""" + assert self._coords is not None and self._coords.g is not None + logger.info("Taking a RFO step") + + if self.iteration != 0: + self._coords.update_h_from_old_h( + self._history.penultimate, self._hessian_update_types + ) + assert self._coords.h is not None + h_n, _ = self._coords.h.shape + + # Form the augmented Hessian, structure from ref [1], eqn. (56) + aug_H = np.zeros(shape=(h_n + 1, h_n + 1)) + + aug_H[:h_n, :h_n] = self._coords.h + aug_H[-1, :h_n] = self._coords.g + aug_H[:h_n, -1] = self._coords.g + + aug_H_lmda, aug_H_v = np.linalg.eigh(aug_H) + # A RF step uses the eigenvector corresponding to the lowest non zero + # eigenvalue + mode = np.where(np.abs(aug_H_lmda) > 1e-16)[0][0] + logger.info(f"Stepping along mode: {mode}") + + # and the step scaled by the final element of the eigenvector + delta_s = aug_H_v[:-1, mode] / aug_H_v[-1, mode] + + self._take_step_within_trust_radius(delta_s) + return None + + def _initialise_run(self) -> None: + """ + Initialise the energy, gradient, and initial Hessian to use + """ + assert self._species is not None, "Must have a species to init" + + self._coords = CartesianCoordinates(self._species.coordinates).to( + "dic" + ) + self._coords.update_h_from_cart_h(self._low_level_cart_hessian) + self._coords.make_hessian_positive_definite() + self._update_gradient_and_energy() + + return None + + @property + @work_in_tmp_dir(use_ll_tmp=True) + def _low_level_cart_hessian(self) -> "Hessian": + """ + Calculate a Hessian matrix using a low-level method, used as the + estimate from which BFGS updates are applied. To ensure steps are taken + in the minimising direction the Hessian MUST be positive definite + see e.g. (https://manual.q-chem.com/5.2/A1.S2.html). To ensure this + condition is satisfied + """ + from autode.methods import get_lmethod + + assert self._species is not None, "Must have a species" + + logger.info("Calculating low-level Hessian") + + species = self._species.copy() + species.calc_hessian(method=get_lmethod(), n_cores=self._n_cores) + assert species.hessian is not None, "Hessian calculation must be ok" + + return species.hessian + + def _take_step_within_trust_radius( + self, delta_s: np.ndarray, factor: float = 1.0 + ) -> float: + """ + Update the coordinates while ensuring the step isn't too large in + cartesian coordinates + + ----------------------------------------------------------------------- + Arguments: + delta_s: Step in internal coordinates + + Returns: + factor: The coefficient of the step taken + """ + assert self._coords is not None, "Must have coordinates" + + if len(delta_s) == 0: # No need to sanitise a null step + return 0.0 + + self._coords.allow_unconverged_back_transform = True + step = factor * delta_s + new_coords = self._coords + step + cartesian_delta = new_coords.to("cart") - self._coords.to("cart") + max_component = np.max(np.abs(cartesian_delta)) + + if max_component > self.alpha: + logger.info( + f"Calculated step is too large ({max_component:.3f} Å)" + f" - scaling down" + ) + + # Note because the transformation is not linear this will not + # generate a step exactly max(∆x) ≡ α, but is empirically close + factor = self.alpha / max_component + step = factor * delta_s + + self._coords.allow_unconverged_back_transform = False + self._coords = self._coords + step + return factor diff --git a/autodE/source/autode/opt/optimisers/steepest_descent.py b/autodE/source/autode/opt/optimisers/steepest_descent.py new file mode 100644 index 0000000000000000000000000000000000000000..e67980ec69272da30ae02ec1f443cb07c4153b19 --- /dev/null +++ b/autodE/source/autode/opt/optimisers/steepest_descent.py @@ -0,0 +1,62 @@ +from abc import ABC +from autode.opt.coordinates.cartesian import CartesianCoordinates +from autode.opt.optimisers.base import NDOptimiser + + +class SteepestDescent(NDOptimiser, ABC): + def __init__(self, maxiter, conv_tol, step_size=0.2, **kwargs): + """ + Steepest decent optimiser + + ---------------------------------------------------------------------- + Arguments: + step_size (float): Size of the step to take. Units of distance + + See Also: + + :py:meth:`NDOptimiser ` + """ + super().__init__(maxiter=maxiter, conv_tol=conv_tol, **kwargs) + + self.alpha = step_size + + def _step(self) -> None: + r""" + Take a steepest decent step: + + .. math:: + + x_{i+1} = x_{i} - \alpha \nabla E + + where :math:`\alpha` is the step size. + """ + assert self._coords is not None, "A step requires set coordinates" + assert self._coords.g is not None, "A step requires a defined gradient" + + self._coords = self._coords - self.alpha * self._coords.g + + +class CartesianSDOptimiser(SteepestDescent): + """Steepest decent optimisation in Cartesian coordinates""" + + def _initialise_run(self) -> None: + """ + Initialise a set of cartesian coordinates. As a species' coordinates + are already Cartesian there is nothing special to do + """ + assert self._species is not None + self._coords = CartesianCoordinates(self._species.coordinates) + self._update_gradient_and_energy() + + +class DIC_SD_Optimiser(SteepestDescent): + """Steepest decent optimisation in delocalised internal coordinates""" + + def _initialise_run(self) -> None: + """Initialise the delocalised internal coordinates""" + assert self._species is not None + + self._coords = CartesianCoordinates(self._species.coordinates).to( + "dic" + ) + self._update_gradient_and_energy() diff --git a/autodE/source/autode/opt/optimisers/utils.py b/autodE/source/autode/opt/optimisers/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..936d7a2c78063a8e6f742085c6917ef4ef194825 --- /dev/null +++ b/autodE/source/autode/opt/optimisers/utils.py @@ -0,0 +1,159 @@ +""" +Various operations for coordinates and optimisers +""" +import numpy as np +from numpy.polynomial import Polynomial +from typing import Union, TYPE_CHECKING + +if TYPE_CHECKING: + from autode.opt.coordinates.base import OptCoordinates + + +class TruncatedTaylor: + """The truncated taylor surface from current grad and hessian""" + + def __init__( + self, + centre: Union["OptCoordinates", np.ndarray], + grad: np.ndarray, + hess: np.ndarray, + ): + """ + Second-order Taylor expansion around a point + + Args: + centre (OptCoordinates|np.ndarray): The coordinate point + grad (np.ndarray): Gradient at that point + hess (np.ndarray): Hessian at that point + """ + self.centre = centre + if hasattr(centre, "e") and centre.e is not None: + self.e = centre.e + else: + # the energy can be relative and need not be absolute + self.e = 0.0 + self.grad = grad + self.hess = hess + n_atoms = grad.shape[0] + assert hess.shape == (n_atoms, n_atoms) + + def value(self, coords: np.ndarray) -> float: + """Energy (or relative energy if point did not have energy)""" + # E = E(0) + g^T . dx + 0.5 * dx^T. H. dx + dx = (coords - self.centre).flatten() + new_e = self.e + np.dot(self.grad, dx) + new_e += 0.5 * np.linalg.multi_dot((dx, self.hess, dx)) + return new_e + + def gradient(self, coords: np.ndarray) -> np.ndarray: + """Gradient at supplied coordinate""" + # g = g(0) + H . dx + dx = (coords - self.centre).flatten() + new_g = self.grad + np.matmul(self.hess, dx) + return new_g + + +def _get_energies_proj_gradients( + coords0: "OptCoordinates", coords1: "OptCoordinates" +): + """ + Get energies and projected gradients from two set of + coordinates + """ + assert coords0.e and coords1.e + assert coords0.g is not None and coords1.g is not None + dist_vec = coords1.raw - coords0.raw + e0 = float(coords0.e) + g0 = np.dot(coords0.g, dist_vec) + e1 = float(coords1.e) + g1 = np.dot(coords1.g, dist_vec) + return e0, e1, g0, g1 + + +class Polynomial2PointFit(Polynomial): + """ + 1D polynomial along the line connecting two coordinates + """ + + @classmethod + def cubic_fit(cls, coords0: "OptCoordinates", coords1: "OptCoordinates"): + """ + Obtain a cubic polynomial from the energies and gradients + at two sets of coordinates: f(x) = d + cx + bx**2 + ax**3 + + Args: + coords0 (OptCoordinates): + coords1 (OptCoordinates): + + Returns: + (Polynomial2PointFit): The fitted polynomial (normalised) + """ + e0, e1, g0, g1 = _get_energies_proj_gradients(coords0, coords1) + # f(0) = d; f(1) = a + b + c + d + d = e0 + # f'(0) = c => a + b = f(1) - c - d + c = g0 + a_b = e1 - c - d + # f'(1) = 3a + 2b + c => 3a + 2b = f'(1) - c + a3_2b = g1 - c + a = a3_2b - 2 * a_b + b = a_b - a + return cls([d, c, b, a]) + + def get_extremum( + self, l_bound: float = 0.0, u_bound: float = 1.0, get_max=False + ) -> Union[float, None]: + """ + Obtain the maximum/minimum of a polynomial f(x), within + two bounds. If there are multiple, return the highest/lowest + respectively. + + Args: + l_bound (float): + u_bound (float): + get_max (bool): Maximum or minimum requested + + Returns: + (float|None): The x value at min or max f(x), None + if not found or not within bounds + """ + # points with derivative 0 are critical points + crit_points = self.deriv().roots() + + if l_bound > u_bound: + u_bound, l_bound = l_bound, u_bound + crit_points = crit_points[crit_points < u_bound] + crit_points = crit_points[crit_points > l_bound] + + if len(crit_points) == 0: + return None + + maxima = [] + minima = [] + for point in crit_points: + for i in range(2, 6): + ith_deriv = self.deriv(i)(point) + # if zero, move up an order of derivative + if -1.0e-14 < ith_deriv < 1.0e-14: + continue + # derivative > 0 and i is even => max + elif ith_deriv < 0 and i % 2 == 0: + maxima.append(point) + # derivative > 0 and i is even => min + elif ith_deriv > 0 and i % 2 == 0: + minima.append(point) + # otherwise inflection point + else: + break + + if get_max: + if len(maxima) == 0: + return None + max_vals = [self(x) for x in maxima] + return maxima[np.argmax(max_vals)] + + else: + if len(minima) == 0: + return None + min_vals = [self(x) for x in minima] + return minima[np.argmin(min_vals)] diff --git a/autodE/source/autode/path/__init__.py b/autodE/source/autode/path/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d90d12446b2816f5c49dee17a412f0ff5c3d899f --- /dev/null +++ b/autodE/source/autode/path/__init__.py @@ -0,0 +1,5 @@ +from autode.path.path import Path +from autode.path.adaptive import AdaptivePath + + +__all__ = ["Path", "AdaptivePath"] diff --git a/autodE/source/autode/path/adaptive.py b/autodE/source/autode/path/adaptive.py new file mode 100644 index 0000000000000000000000000000000000000000..6481ef324c0fe1e6d5b621065a5268823bbd356d --- /dev/null +++ b/autodE/source/autode/path/adaptive.py @@ -0,0 +1,385 @@ +import autode as ade +import numpy as np + +from autode.log import logger +from autode.path.path import Path +from autode.transition_states.ts_guess import TSguess +from autode.utils import work_in +from autode.constraints import DistanceConstraints +from autode.bonds import ScannedBond + +from typing import TYPE_CHECKING, List, Optional + + +if TYPE_CHECKING: + from autode.species import ReactantComplex, ProductComplex, Species + from autode.transition_states import TSguess + from autode.wrappers.methods import Method + from autode.bond_rearrangement import BondRearrangement + from autode.wrappers.keywords.keywords import OptKeywords + + +def get_ts_adaptive_path( + reactant: "ReactantComplex", + product: "ProductComplex", + method: "Method", + bond_rearr: "BondRearrangement", + name: str = "adaptive", +) -> Optional[TSguess]: + """ + Generate a TS guess geometry based on an adaptive path along multiple + breaking and/or forming bonds + + --------------------------------------------------------------------------- + Arguments: + reactant (autode.species.ReactantComplex): + + product (autode.species.ProductComplex): + + method (autode.wrappers.base.ElectronicStructureMethod): + + bond_rearr (autode.bond_rearrangement.BondRearrangement): + + name (str): + + Returns: + (autode.transition_states.ts_guess.TSguess | None): + """ + fbonds, bbonds = bond_rearr.fbonds, bond_rearr.bbonds + + ts_path = AdaptivePath( + init_species=reactant, + bonds=pruned_active_bonds(reactant, fbonds, bbonds), + method=method, + final_species=product, + ) + ts_path.generate(name=name) + + if ts_path.peak_idx is None: + logger.warning("Adaptive path had no peak") + return None + + ts_guess = TSguess( + atoms=ts_path[ts_path.peak_idx].atoms, + reactant=reactant, + product=product, + bond_rearr=bond_rearr, + name=name, + ) + return ts_guess + + +def pruned_active_bonds( + reactant: "ReactantComplex", fbonds: list, bbonds: list +) -> List[ScannedBond]: + """ + Prune the set of forming and breaking bonds for special cases + + (1) Three bonds form a ring, in which case the adaptive path may fail to + traverse the MEP. If so then delete the breaking bond with the largest + overlap to the forming bond e.g.:: + + H + / \ + M --- C + + where all the bonds drawn are active and the C-H bond is forming + + + --------------------------------------------------------------------------- + Arguments: + reactant (autode.species.Species): + + fbonds (list(autode.pes.pes.FormingBond)): + + bbonds (list(autode.pes.pes.BreakingBond)): + + Returns: + (list(autode.pes.pes.ScannedBond)): + """ + logger.info("Pruning active bonds for special cases") + + # Flat list of all the atom indexes in the breaking/forming bonds + a_atoms = [bond.atom_indexes for bond in fbonds + bbonds] + + coords = reactant.coordinates + + if len(fbonds) == 1 and len(bbonds) == 2 and len(set(a_atoms)) == 3: + logger.info("Found 3-membered ring with 2 breaking & 1 forming bonds") + + f_i, f_j = fbonds[0].atom_indexes + f_vec = coords[f_i] - coords[f_j] + f_vec /= np.linalg.norm(f_vec) + + b0_i, b0_j = bbonds[0].atom_indexes + b0_projection = np.dot((coords[b0_i] - coords[b0_j]), f_vec) + + b1_i, b1_j = bbonds[1].atom_indexes + b1_projection = np.dot((coords[b1_i] - coords[b1_j]), f_vec) + + if b0_projection > b1_projection: + logger.info(f"Excluding {bbonds[0]}") + bbonds.pop(0) + else: + logger.info(f"Excluding {bbonds[1]}") + bbonds.pop(1) + + if any(bond.dr < 0 for bond in bbonds): + logger.info( + "Found at least one breaking bond where the final distance" + " is shorter than the initial - removing" + ) + """ + Counterintuitively, this is possible e.g. metallocyclobutate formation + from a metalocyclopropane and a alkylidene (due to the way bonds are + defined) + """ + bbonds = [bond for bond in bbonds if bond.dr > 0] + + return fbonds + bbonds + + +class AdaptivePath(Path): + def __init__( + self, + bonds: List[ScannedBond], + method: "Method", + init_species: Optional["Species"] = None, + final_species: Optional["Species"] = None, + ): + """ + PES Path + + ----------------------------------------------------------------------- + Arguments: + init_species (autode.species.Species): + + bonds (list(autode.pes.ScannedBond)): + + method (autode.wrappers.base.ElectronicStructureMethod): + + final_species (autode.species.Species): + """ + super().__init__() + + self.method = method + self.bonds = bonds + self.final_species = final_species + + # Add the first point - will run a constrained minimisation if possible + if init_species is not None: + point = init_species.new_species() + point.constraints.distance = DistanceConstraints( + {b.atom_indexes: b.curr_dist for b in bonds} + ) + self.append(point) + + self._check_bonds_have_initial_and_final_distances() + + def __eq__(self, other): + """Equality of two adaptive paths""" + if not isinstance(other, AdaptivePath): + return False + + return super().__eq__(other) + + def _check_bonds_have_initial_and_final_distances(self) -> None: + for bond in self.bonds: + assert bond.curr_dist is not None and bond.final_dist is not None + + @work_in("initial_path") + def append(self, point) -> None: + """ + Append a point to the path and optimise it + + ----------------------------------------------------------------------- + Arguments: + point (Species): Point on a path + + Raises: + (autode.exceptions.CalculationException): + """ + + idx = len(self) - 1 + keywords: "OptKeywords" = self.method.keywords.low_opt.copy() + keywords.max_opt_cycles = 50 + + calc = ade.Calculation( + name=f"path_opt{idx}", + molecule=point, + method=self.method, + keywords=keywords, + n_cores=ade.Config.n_cores, + ) + calc.run() + point.reset_graph() + + if self.method.name == "xtb" or self.method.name == "mopac": + # XTB prints gradients including the constraints, which are ~0 + # the gradient here is just the derivative of the electronic energy + # so rerun a gradient calculation, which should be very fast + # while MOPAC doesn't print gradients for a constrained opt + tmp_point_for_grad = point.new_species() + assert self.method.keywords.grad is not None + + calc = ade.Calculation( + name=f"path_grad{idx}", + molecule=tmp_point_for_grad, + method=self.method, + keywords=self.method.keywords.grad, + n_cores=ade.Config.n_cores, + ) + calc.run() + calc.clean_up(force=True, everything=True) + assert tmp_point_for_grad.gradient is not None + point.gradient = tmp_point_for_grad.gradient + + return super().append(point) + + def plot_energies( + self, save=True, name="init_path", color="k", xlabel="ζ" + ) -> None: + return super().plot_energies(save, name, color, xlabel) + + def contains_suitable_peak(self) -> bool: + """Does this path contain a peak suitable for a TS guess?""" + if not self.contains_peak: + return False + + assert self.peak_idx, "Must have a peak_idx if contains_peak" + + if self.final_species is None: + logger.warning( + "No final species set. Can't check peak suitability" + ) + return False + + idx = self.product_idx(product=self.final_species) + if idx is not None and self[idx].energy < self[self.peak_idx].energy: + logger.info("Products made and have a peak. Assuming suitable!") + return True + + # Products aren't made by isomorphism, but we may still have a suitable peak + if any( + self[-1].constraints.distance[b.atom_indexes] == b.final_dist + for b in self.bonds + ): + logger.warning( + "Have a peak, products not made on isomorphism, but" + " at least one of the distances is final. Assuming " + "the peak is suitable " + ) + return True + + return False + + def _adjust_constraints(self, point): + """ + Adjust the geometry constraints based on the final point + + ----------------------------------------------------------------------- + Arguments: + point (autode.neb.PathPoint): + """ + logger.info(f"Adjusting constraints on point {len(self)}") + + # Flat list of all the atom indexes involved in the bonds + atom_idxs = [i for bond in self.bonds for i in bond] + + max_step, min_step = ade.Config.max_step_size, ade.Config.min_step_size + + for bond in self.bonds: + (i, j), coords = bond.atom_indexes, self[-1].coordinates + + # Normalised r_ij vector + vec = coords[j] - coords[i] + vec /= np.linalg.norm(vec) + + # Calculate |∇E_i·r| i.e. the gradient along the bond. Positive + # values are downhill in energy to form the bond and negative + # downhill to break it + gradi = np.dot(self[-1].gradient[i], vec) # |∇E_i·r| bond midpoint + gradj = np.dot(self[-1].gradient[j], -vec) + + # Exclude gradients from atoms that are being substituted + if atom_idxs.count(i) > 1: + grad = gradj + elif atom_idxs.count(j) > 1: + grad = gradi + else: + grad = np.average((gradi, gradj)) + + logger.info(f"|∇E_i·r| = {grad:.4f} on {bond}") + + # Downhill in energy to break/form this breaking/forming bond + if grad * np.sign(bond.dr) > 0: + dr = np.sign(bond.dr) * ade.Config.max_step_size + + # otherwise use a scaled value, depending on the gradient + # large values will have small step sizes, down to min_step Å + else: + dr = (max_step - min_step) * np.exp( + -((grad / 0.05) ** 2) + ) + min_step + dr *= np.sign(bond.dr) + + new_dist = point.distance(*bond.atom_indexes) + dr + + # No need to go exceed final distances on forming/breaking bonds + if bond.forming and new_dist < bond.final_dist: + new_dist = bond.final_dist + + elif bond.breaking and new_dist > bond.final_dist: + new_dist = bond.final_dist + + else: + logger.info(f"Using step {dr:.3f} Å on bond: {bond}") + + point.constraints.distance[bond.atom_indexes] = new_dist + + return None + + def generate(self, init_step_size=0.2, name="initial") -> None: + """ + Generate the path from the starting point; can be called only once! + + ----------------------------------------------------------------------- + Keyword arguments: + init_step_size (float): Initial step size in all bonds to calculate + the gradient + + name (str): Prefix to use for saved plot and geometries + """ + logger.info("Generating path from the initial species") + assert len(self) == 1 + + # Always perform an initial step linear in all bonds + logger.info("Performing a linear step and calculating gradients") + point = self[0].new_species(with_constraints=True) + + for bond in self.bonds: + # Shift will be -min_step_size if ∆r is negative and larger than + # the minimum step size + dr = np.sign(bond.dr) * min(init_step_size, np.abs(bond.dr)) + point.constraints.distance[bond.atom_indexes] += dr + + self.append(point) + logger.info("First point found") + + def reached_final_point(): + """Are there any more points to add?""" + return all( + point.constraints.distance[b.atom_indexes] == b.final_dist + for b in self.bonds + ) + + logger.info("Adaptively adding points to the path") + while not (reached_final_point() or self.contains_suitable_peak()): + point = self[-1].new_species(with_constraints=True) + self._adjust_constraints(point=point) + self.append(point) + + self.plot_energies(name=f"{name}_path") + self.print_geometries(name=f"{name}_path") + + return None diff --git a/autodE/source/autode/path/interpolation.py b/autodE/source/autode/path/interpolation.py new file mode 100644 index 0000000000000000000000000000000000000000..43fab786e4b68db99dda92fff0f6d17bf2b545a0 --- /dev/null +++ b/autodE/source/autode/path/interpolation.py @@ -0,0 +1,259 @@ +""" +Routines for interpolation of a path or series of images +""" +from typing import List, Optional, Union, Sequence, TYPE_CHECKING +import numpy as np +from math import sqrt +from scipy.interpolate import CubicSpline, CubicHermiteSpline +from scipy.integrate import quad +from scipy.optimize import root_scalar + +if TYPE_CHECKING: + from autode.species.species import Species + from autode.values import Energy + from scipy.interpolate import PPoly + + +class CubicPathSpline: + """ + Smooth cubic spline interpolation through a path i.e. a series + of images, or coordinates. Optionally also fits the energy. + """ + + def __init__( + self, + coords_list: Sequence[np.ndarray], + energies: Optional[Sequence[Union[float, "Energy"]]] = None, + ): + """ + Initialise a spline representation of path from list of coordinates + and energies, if provided + + Args: + coords_list (Sequence[np.ndarray]): List of coordinates + energies (Sequence[float|Energy]): List of energies + """ + # cast all coordinates into flat arrays, and check size + coords_list = [np.array(coords).flatten() for coords in coords_list] + assert all( + coords.shape == coords_list[0].shape for coords in coords_list + ) + + self._path_spline = self._spline_from_coords(coords_list) + self._energy_spline: Optional["PPoly"] = None + + if energies is not None: + self.fit_energies(energies) + + @staticmethod + def _spline_from_coords(coords_list: Sequence[np.ndarray]): + """ + Obtain a cubic spline from a set of flat coordinates + + Args: + coords_list (Sequence[np.ndarray]): List of flat arrays, all + must have same dimensions + + Returns: + (PPoly): The fitted path spline + """ + # Estimate normalised distances by adjacent Euclidean distances + distances = [0.0] + [ + np.linalg.norm(coords_list[idx + 1] - coords_list[idx]) + for idx in range(len(coords_list) - 1) + ] + path_distances = np.cumsum(distances) + path_distances /= max(path_distances) + coords_data = np.array(coords_list) + return CubicSpline(x=path_distances, y=coords_data, axis=0) + + @property + def path_distances(self) -> List[float]: + """ + Locations of each point in the current spline, according + to normalised Euclidean distances (chord-length parameterisation) + + Returns: + (list[float]): + """ + assert self._path_spline is not None + return list(self._path_spline.x) + + def fit_energies(self, energies: Sequence[Union[float, "Energy"]]) -> None: + """ + Fit the energy spline based on the distances of + the current path spline, and supplied energy values. + Will overwrite any energies used during init. + + Args: + energies (Sequence[float|Energy]): + """ + energies = [float(energy) for energy in energies] + self._energy_spline = CubicSpline( + x=self.path_distances, + y=energies, + ) + return None + + def coords_at(self, path_distance: float) -> np.ndarray: + """Spline-predicted coordinates at a point""" + return self._path_spline(path_distance) + + def energy_at(self, path_distance: float) -> float: + """Spline-predicted energy at a point""" + if self._energy_spline is None: + raise RuntimeError( + "Must have fitted energies before calling energy_at()" + ) + return self._energy_spline(path_distance) + + @classmethod + def from_species_list( + cls, species_list: Sequence["Species"] + ) -> "CubicPathSpline": + """ + Obtain a cubic spline from a list of species. Will fit energies if they + are available on all species provided. + + Args: + species_list (Sequence[Species]): The list of species in the path, + in the order that they appear + + Returns: + (PathSpline): + """ + coords_list = [ + np.array(mol.coordinates).flatten() for mol in species_list + ] + + energies: Optional[list] = [mol.energy for mol in species_list] + if any(mol.energy is None for mol in species_list): + energies = None + + return cls(coords_list=coords_list, energies=energies) # type: ignore + + def path_integral( + self, l_bound: float = 0.0, u_bound: float = 1.0 + ) -> float: + """ + Integrate the parametric spline to obtain the length of the + path, in a given range. The bounds should be ideally in the + range [0, 1], beyond that range the spline extrapolation is + unreliable. + + Args: + l_bound (float): Lower bound of integration + u_bound (float): Upper bound of integration + + Returns: + (float): The path length in the units of the coordinates fitted + """ + deriv = self._path_spline.derivative() + assert deriv(l_bound).shape[0] > 1 + + assert l_bound < u_bound, "Lower bound must be less than upper bound" + + def dpath(t): + return sqrt(np.sum(np.square(deriv(t)))) + + path_length = quad( + func=dpath, + a=l_bound, + b=u_bound, + epsabs=1.0e-6, + limit=100, + ) + + return path_length[0] + + def integrate_upto_length(self, span: float) -> float: + """ + Solve the value of x for which path integral from 0 to x will + be equal to the given length. + + Args: + span (float): The specified length in the units of the + fitted coordinates (must be positive) + + Returns: + (float): The solution + """ + + # Find bounds for root search + def span_error(x): + return self.path_integral(0, x) - span + + assert span > 0 + bracket_left = None + bracket_right = None + + x_tmp = span + for _ in range(500): + if span_error(x_tmp) < 0: + bracket_left = x_tmp + x_tmp = x_tmp * 1.5 + else: + bracket_right = x_tmp + x_tmp = x_tmp * 0.5 + if bracket_left is not None and bracket_right is not None: + break + + assert ( + bracket_left is not None and bracket_right is not None + ), "Unable to find range for root search to integrate upto length" + + res = root_scalar( + f=span_error, + bracket=[bracket_left, bracket_right], + method="brentq", + xtol=1.0e-5, + ) + + assert res.converged, "Failed to integrate upto length!" + return float(res.root) + + def energy_peak( + self, l_bound: float = 0.0, u_bound: float = 1.0 + ) -> Optional[float]: + """ + Get the peak of the path within a given range, + by using the energy spline + + Args: + l_bound (float): Lower bound of range + u_bound (float): Upper bound of range + + Returns: + (float|None): Position of the peak, None if not found + + Raises: + RuntimeError: If energy was not fitted + """ + if self._energy_spline is None: + raise RuntimeError( + "Energy spline must be fitted before calling peak_x()" + ) + # cast into proper types + l_bound = float(l_bound) + u_bound = float(u_bound) + + deriv = self._energy_spline.derivative() + # Obtain the roots of first derivative + roots = deriv.roots(discontinuity=False, extrapolate=False) + roots = roots[(roots < u_bound) & (roots > l_bound)] + + all_possible_points = [u_bound, l_bound] + list(roots) + values = [] + for x in all_possible_points: + # get the predicted energy from spline + values.append(float(self._energy_spline(x))) + + # Extreme value theorem means that inside a bound, there + # must be a highest and lowest point on a continuous function + # So, the highest point must be a maximum (within bounds) + peak = np.argmax(values) + if peak in [0, 1]: + # means the highest point is on one of the bounds i.e. no peak + return None + else: + return all_possible_points[peak] diff --git a/autodE/source/autode/path/path.py b/autodE/source/autode/path/path.py new file mode 100644 index 0000000000000000000000000000000000000000..64939cc6c2969f4e242a469a41191b0338f01ed0 --- /dev/null +++ b/autodE/source/autode/path/path.py @@ -0,0 +1,193 @@ +import numpy as np + +from autode.species import Species +from autode.input_output import atoms_to_xyz_file +from autode.log import logger +from autode.units import KcalMol + +from typing import Optional + + +class Path(list): + def __init__(self, *args: Species, units=KcalMol): + """ + Base path class that may be populated with species or nudged elastic + band images, *must* have .energy attributes + + ----------------------------------------------------------------------- + Arguments: + args (autode.species.species.Species): + + Keyword Arguments: + units (autode.units.Unit): + """ + super().__init__() + + for arg in args: + assert isinstance(arg, Species) + self.append(arg) + + self.units = units + + def __eq__(self, other): + """Are two paths equal?""" + if not isinstance(other, Path): + return False + + return list.__eq__(self, other) + + @property + def energies(self) -> np.ndarray: + """ + Numpy array of energy for each species/image in this path + + ----------------------------------------------------------------------- + Returns: + (np.ndarray): + """ + return np.array([item.energy for item in self]) + + @property + def rel_energies(self) -> np.ndarray: + """ + "Relative energies in a particular unit + + Returns: + (np.ndarray): + """ + if len(self) == 0: + logger.warning("Cannot determine relative energies with no points") + return np.array([]) + + return self.units.times * (self.energies - np.min(self.energies)) + + @property + def peak_idx(self) -> Optional[int]: + """Get the index of the highest energy peak in this path + + Returns: + (int | None) + """ + if any(item.energy is None for item in self): + logger.warning("An energy was None - cannot locate peak") + return None + + peaks = [i for i in range(1, len(self) - 1) if self.is_saddle(i)] + + if len(peaks) > 0: + peak_rel_es = self.rel_energies[np.array(peaks, dtype=int)] + logger.info( + f"Found peaks at {peaks} with relative energies " + f"∆E = {np.round(peak_rel_es, 1)} kcal mol-1" + ) + + # Return the highest energy peak i.e. sorted high -> low + for peak_idx in sorted(peaks, key=lambda i: -self.energies[i]): + return peak_idx + + return None + + @property + def contains_peak(self) -> bool: + return self.peak_idx is not None + + def product_idx(self, product: "Species") -> Optional[int]: + """ + Get the index of the point in the path at which products are made. + If they are not made or they cannot be checked then return None + + ----------------------------------------------------------------------- + Arguments: + product (autode.species.Species): + + Returns: + (int | None): + """ + if product is None or product.graph is None: + logger.warning("Cannot check if products are made") + return None + + for i, point in enumerate(self): + if product.graph.is_isomorphic_to(point.graph): + logger.info(f"Products made at point {i}") + return i + + return None + + def products_made(self, product: "Species") -> bool: + """Are the products are made on the surface? + + ----------------------------------------------------------------------- + Arguments: + product (autode.species.Species): + + Returns: + (bool): + """ + return self.product_idx(product) is not None + + def is_saddle(self, idx: int) -> bool: + """Is an index a saddle point""" + if idx == 0 or idx == len(self) - 1: + logger.warning("Cannot be saddle point, index was at the end") + return False + + if any(self[i].energy is None for i in (idx - 1, idx, idx + 1)): + logger.error( + f"Could not determine if point {idx} was a saddle " + f"point, an energy close by was None" + ) + return False + + energy = self[idx].energy + return self[idx - 1].energy < energy and self[idx + 1].energy < energy + + def plot_energies( + self, save: bool, name: str, color: str, xlabel: str + ) -> None: + """Plot this path""" + import matplotlib.pyplot as plt + + if len(self) == 0 or any(item.energy is None for item in self): + logger.error("Could not plot a surface, an energy was None") + return + + # Plot the relative energies each iteration as a color gradient + rel_es = self.rel_energies + plt.plot(np.arange(len(self)), rel_es, marker="o", color=color) + + plt.ylim(-0.1 * np.max(rel_es), 1.1 * np.max(rel_es)) + plt.xlabel(xlabel) + plt.ylabel(f"∆$E$ / {self.units.name}") + plt.tight_layout() + + if save: + plt.savefig(f"{name}.pdf") + plt.close() + + return None + + def print_geometries(self, name: str) -> None: + """Print an xyz trajectory of the geometries in the path""" + + open(f"{name}.xyz", "w").close() # Empty the file + + for i, image in enumerate(self): + energy = image.energy if image.energy is not None else "none" + + title_line = ( + f"autodE path point {i}. E = {energy} " + f"charge = {image.charge} " + f"mult = {image.mult} " + ) + + if image.solvent is not None: + title_line += f"solvent = {image.solvent.name} " + + atoms_to_xyz_file( + image.atoms, + f"{name}.xyz", + title_line=title_line, + append=True, + ) + return None diff --git a/autodE/source/autode/pes/__init__.py b/autodE/source/autode/pes/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..114c6aaaed988276d53dcd1525074a196cca2c82 --- /dev/null +++ b/autodE/source/autode/pes/__init__.py @@ -0,0 +1,5 @@ +from autode.pes.relaxed import RelaxedPESnD +from autode.pes.unrelaxed import UnRelaxedPES1D + + +__all__ = ["RelaxedPESnD", "UnRelaxedPES1D"] diff --git a/autodE/source/autode/pes/mep.py b/autodE/source/autode/pes/mep.py new file mode 100644 index 0000000000000000000000000000000000000000..358860cbf0066ccddcb196ff467c5dd3ec7f11f2 --- /dev/null +++ b/autodE/source/autode/pes/mep.py @@ -0,0 +1,45 @@ +""" +Minimum energy path (MEP) finding +""" +import numpy as np +from typing import Tuple +from networkx.generators.lattice import grid_graph +from networkx.algorithms.shortest_paths import dijkstra_path + + +def peak_point( + energies: np.ndarray, + point1: Tuple[int, ...], + point2: Tuple[int, ...], +) -> Tuple[int, ...]: + """ + Find a point in an array of energies that connects two points via + the minimum energy pathway. If there is no peak in the path then the + highest energy point will be returned + + --------------------------------------------------------------------------- + Arguments: + energies: Tensor of energies + + point1: Indices of a point on the surface (len(point1) = energies.ndim) + + point2: Indices of another point on the surface + + Returns: + (tuple(int, ...)): + """ + + def weight(u, v, d): + """Weight between two nodes in the graph is taken as the abs diff""" + return np.abs(energies[u] - energies[v]) + + path = dijkstra_path( + G=grid_graph(dim=energies.shape, periodic=False), + source=point1, + target=point2, + weight=weight, + ) + + peak_idx = np.argmax(np.array([energies[p] for p in path])) + + return path[peak_idx] diff --git a/autodE/source/autode/pes/pes_nd.py b/autodE/source/autode/pes/pes_nd.py new file mode 100644 index 0000000000000000000000000000000000000000..7741c599e102d3af0777c4bce406e8d5b175c525 --- /dev/null +++ b/autodE/source/autode/pes/pes_nd.py @@ -0,0 +1,914 @@ +""" +Potential energy surface in N-dimensions (distances), enables parallel +calculations over the grid of points, location of saddle points in the +surface and connecting minima and saddle points +""" +import numpy as np +import itertools as it + +from abc import ABC, abstractmethod +from typing import ( + Dict, + Tuple, + Union, + Optional, + Sequence, + Iterable, + Type, + TYPE_CHECKING, +) +from scipy.interpolate import RectBivariateSpline + +from autode.config import Config +from autode.log import logger +from autode.values import ValueArray, Energy, Distance, EnergyArray +from autode.units import energy_unit_from_name, ang + +if TYPE_CHECKING: + import scipy + from autode.species.species import Species + from autode.wrappers.methods import Method + from autode.wrappers.keywords import Keywords + +# Type is a dictionary keyed with tuples and has a set of floats* as a value +_rs_type = Dict[Tuple[int, int], Union[Tuple, np.ndarray]] + + +class PESnD(ABC): + """Potential energy surface (PES) in N-dimensions""" + + def __init__( + self, + species: Optional["Species"] = None, + rs: Optional[_rs_type] = None, + allow_rounding: bool = True, + ): + """ + N-dimensional PES for a species in a number of distances, defined by + the 'rs' dictionary containing atom pairs and distances + + ----------------------------------------------------------------------- + Arguments: + species: Initial species which to evaluate from + + rs: Dictionary of atom indexes (indexed from 0) with associated + either initial and final, or just final distances. If + undefined the initial distances are just their current + values. + + allow_rounding: Allow rounding of a step-size to support an + integer number of steps between the initial and + final distances + """ + self._species = species + + self._rs = _ListDistances1D( + species, + rs_dict=rs if rs is not None else {}, + allow_rounding=allow_rounding, + ) + + self._energies = EnergyArray(np.empty(self.shape), units="Ha") + self._init_tensors() + + # Attributes set in calculate() + self._coordinates: Optional[np.ndarray] = None + self._method: Optional["Method"] = None + self._n_cores: int = Config.n_cores + self._keywords: Optional["Keywords"] = None + + @property + def shape(self) -> Tuple[int, ...]: + """ + Shape of the surface, which is the number of points in each dimension + + ----------------------------------------------------------------------- + Returns: + (tuple(int, ...)): + """ + return tuple(len(arr) for arr in self._rs) + + @property + def ndim(self) -> int: + """ + Number of dimensions in this PES + + ----------------------------------------------------------------------- + Returns: + (int): + """ + return len(self._rs) + + @property + def origin(self) -> Tuple[int, ...]: + """ + Tuple of the origin e.g. (0,) in 1D and (0, 0, 0) in 3D + + ----------------------------------------------------------------------- + Returns: + (tuple(int, ...)): + """ + return tuple(0 for _ in range(self.ndim)) + + @property + def relative_energies(self) -> EnergyArray: + """ + Relative energies on this PES + + ----------------------------------------------------------------------- + Returns: + (autode.values.EnergyArray): Numpy array of energies + """ + return self._energies - np.min(self._energies) + + def calculate( + self, + method: "Method", + keywords: Optional["Keywords"] = None, + n_cores: Optional[int] = None, + ) -> None: + """ + Calculate the surface by running calculations over each structure on + the surface. Requires the PES to be initialised with a species + + ----------------------------------------------------------------------- + Arguments: + method: Method to use + + keywords: Keywords to use. If None then will use method.keywords.sp + for an unrelaxed or method.keywords.opt for a relaxed + + n_cores: Number of cores. If None then use ade.Config.n_cores + """ + if self._species is None: + raise ValueError( + "Cannot calculate a PES without an initial " + "species. Initialise PESNd with a species " + "or reactant" + ) + + if keywords is None: + keywords = self._default_keywords(method) + logger.info( + "PES calculation keywords not specified, using:\n" + f"{keywords}" + ) + else: + keywords = self._default_keyword_type(keywords) + + self._keywords = keywords + self._init_tensors() + assert self._coordinates is not None, "Expecting coords to be set" + + # Set the coordinates of the first point in the PES, and other attrs + self._coordinates[self.origin] = self._species.coordinates + self._method = method + self._n_cores = Config.n_cores if n_cores is None else n_cores + + self._calculate() + + if not self._has_energy(self.origin): + raise RuntimeError( + "PES calculation failed. Not even the first " + "point had an energy" + ) + + return None + + def plot( + self, + filename: Optional[str] = "PES.pdf", + interp_factor: int = 0, + units: str = "kcal mol-1", + ) -> None: + """ + Plot this PES along a number of dimensions + + ----------------------------------------------------------------------- + Arguments: + + filename: Name of the file to save, type inferred from extension. + If None then return .show() on the matplotlib plot + + interp_factor: Factor by which to interpolate the surface with, + if 0 (the default) then no interpolation is used + + units: Units of the surface. One of {'Ha', 'eV', 'kcal', 'kJ'} + """ + import matplotlib.pyplot as plt + + if interp_factor < 0: + raise ValueError( + f"Unsupported interpolation factor: " + f"{interp_factor}, must be >= 0" + ) + + logger.info(f"Plotting the {self.ndim}D-PES") + self._set_mpl_params() + + if self.ndim == 1: + self._plot_1d(interp_factor, units_name=units) + + elif self.ndim == 2: + self._plot_2d(interp_factor, units_name=units) + + else: + raise NotImplementedError( + f"Cannot plot a surface in {self.ndim} " f"dimensions" + ) + + plt.tight_layout() + plt.subplots_adjust(wspace=0.4 if self.ndim > 1 else None) + plt.savefig(filename, dpi=500) if filename is not None else plt.show() + plt.close() + return None + + def clear(self) -> None: + """ + Clear the energies, derivatives of, and coordinates on this surface + """ + return self._init_tensors() + + def save(self, filename: str) -> None: + """ + Save the PES as a text file (.txt) or compressed numpy file (.npz). If + .npz then re-loading is possible, either into a PES or otherwise in + pure numpy. If .txt then will save only the energies in units of + Hartrees. + + ----------------------------------------------------------------------- + Arguments: + filename: Name of the file to save. (.txt or .npz) If unknown + extension .npz will be added + """ + if len(self._rs) == 0: + raise ValueError("Cannot save an empty PES") + + if filename.endswith(".txt"): + self._save_txt(filename) + + else: + self._save_npz(filename) + + return None + + def load(self, filename: str) -> None: + """ + Load a PES from a saved numpy file + + ----------------------------------------------------------------------- + Arguments: + filename: + + Raises: + (FileNotFoundError): + """ + + if not filename.endswith(".npz"): + raise ValueError( + f"Cannot reload a PES from {filename}. Must be a " + f".npz compressed numpy file" + ) + + data = np.load(filename, allow_pickle=True) + self._energies = EnergyArray(data["E"], units="Ha") + self._coordinates = data["R"] + + # Maximum dimension is the largest integer out of e.g. 'r0', 'r1', ... + ndim = max(int(key.split("r")[1]) for key in data.keys() if "r" in key) + self._rs = _ListDistances1D(species=None, rs_dict={}) + + for i in range(ndim): + idx_i, idx_j = tuple(int(idx) for idx in data[f"a{i+1}"]) + + self._rs.append( + _Distances1D( + input_array=data[f"r{i + 1}"], atom_idxs=(idx_i, idx_j) + ) + ) + + self._mesh() + return None + + @classmethod + def from_file(cls, filename: str) -> "PESnD": + """ + Load a potential energy surface from a compressed numpy file (.npz) + + ----------------------------------------------------------------------- + Arguments: + filename: Name of the file to load + + Returns: + (autode.pes.pes_nd.PESnD): + """ + pes = cls(species=None, rs={}) + pes.load(filename=filename) + + return pes + + @abstractmethod + def _default_keywords(self, method: "Method") -> "Keywords": + """ + Default keywords to use for this type of PES e.g. opt or sp + + ----------------------------------------------------------------------- + Arguments: + method: + + Returns: + (autode.wrappers.keywords.Keywords): + """ + + @property + @abstractmethod + def _default_keyword_type(self) -> Type["Keywords"]: + """Default keyword type e.g. OptKeywords for a relaxed PES""" + + @abstractmethod + def _calculate(self) -> None: + """Calculate the surface, using _method, _keywords, _n_cores attrs""" + + @property + def _tensors(self) -> Sequence[np.ndarray]: + """Tensors that can be nan-ed""" + return [self._energies] + + def _init_tensors(self) -> None: + """ + Initialise the tensors for the energy (i.e. values on this surface) + and gradients with respect to each dimension of the surface + """ + + for tensor in self._tensors: + tensor.fill(np.nan) + + if self._species is not None: + # Coordinates tensor is the shape of the PES plus (N, 3) dimensions + self._coordinates = np.zeros( + (*self.shape, self._species.n_atoms, 3), dtype=np.float64 + ) + + if hasattr(self, "_coordinates") and self._coordinates is not None: + self._coordinates.fill(0.0) + + self._mesh() # Mesh each coordinate and dynamically add r1, r2.. attrs + return None + + def _mesh(self) -> None: + """ + Dynamically add public attributes for r1, r2, ... etc. as nD arrays. + For example if _rs contains two lists of [0.0, 0.1] then this function + adds self.r1 and self.r2, each with indexing appropriate for the + value of r1 at the point (0, 0) on the grid. + """ + + for i, meshed_rs in enumerate(np.meshgrid(*self._rs, indexing="ij")): + setattr(self, f"r{i+1}", meshed_rs) + + return None + + def _points(self) -> Iterable[Tuple]: + """ + A list of points in this PES sorted by their sum. For example, for a + 1D PES containing 3 points the list is: [(0,), (1,), (2,)] while for + a 2D PES of 4 total points the points list is: + [(0, 0), (0, 1), (1, 0), (1, 1)] + used for enumerating over the surface in from the initial species + point (located at the origin). + + ----------------------------------------------------------------------- + Returns: + (list(tuple(int, ..))): List of points + """ + ranges = (range(len(r)) for r in self._rs) + + return sorted(it.product(*ranges), key=lambda x: sum(x)) + + def _point_name(self, point: Tuple) -> str: + """ + Name of a particular point in the surface + + ----------------------------------------------------------------------- + Arguments: + point: Indices of the point + + Returns: + (str): + """ + assert self._species is not None, "Must have a species to name a point" + return f'{self._species.name}_scan_{"-".join([str(p) for p in point])}' + + def _is_contained(self, point: Tuple) -> bool: + """ + Is a point contained on this PES, defined by its indices. For example, + (-1,) is never on a 1D PES, (2,) is on a 1D PES with 3 points in it + and (1,) is not on a 2D PES as it doesn't have the same dimension + + ----------------------------------------------------------------------- + Arguments: + point: Indices of a point on the grid + + Returns: + (bool): If the point is on the PES + """ + if len(point) != self.ndim: + return False + + if sum(point) < 0: + return False + + if any(p_n >= s_n or p_n < 0 for p_n, s_n in zip(point, self.shape)): + return False + + return True + + def _has_energy(self, point: Tuple) -> bool: + """ + Does a point have a defined energy? Energies are initialised to + zero, while failed calculations have np.nan energy. + + ----------------------------------------------------------------------- + Arguments: + point: + + Returns: + (bool): + """ + return self._is_contained(point) and not np.isnan( + self._energies[point] + ) + + @staticmethod + def _neighbour(point: Tuple, dim: int, delta: int) -> Tuple: + r""" + Generate a neighbour of a particular point on the surface. Example:: + + Point dim delta --> Result + ------------------------------------- + (0,) 0 1 (1,) + (1, 2) 0 2 (3, 2) + (1, 2) 1 1 (2, 3) + + ----------------------------------------------------------------------- + Arguments: + point: Current point + + dim: Dimension in which to find a neighbour + + delta: Order of the neighbour e.g. 1 => nearest neighbour etc. + + Returns: + (tuple(int, ..)): Point + + Raises: + (ValueError, IndexError): + """ + if delta == 0: + raise ValueError( + "Cannot find a neighbour using ∆=0 in dimension " f"{dim}" + ) + + new_point = list(point) + new_point[dim] += delta + return tuple(new_point) + + def _r(self, point: Tuple, dim: int) -> float: + """ + Value of r at a particular point, in a given dimension e.g. (0,) in + a 1D surface in dim 0 -> r_1[0] + + ----------------------------------------------------------------------- + Arguments: + point: Point on the surface + + dim: Dimension on the surface (indexed from 0) + + Returns: + (float): r + """ + idx = point[dim] + return self._rs[dim][idx] + + def _distance(self, point1: Tuple, point2: Tuple) -> Distance: + """ + Distance between two points on this surface + + ----------------------------------------------------------------------- + Args: + point1: Point on the surface + point2: + + Returns: + (autode.values.Distance): + """ + r1 = np.array([self._r(point1, i) for i in range(self.ndim)]) + r2 = np.array([self._r(point2, i) for i in range(self.ndim)]) + + return Distance(np.linalg.norm(r1 - r2), units="Å") + + def _save_txt(self, filename: str) -> None: + """ + Save a pure .txt file of the energies contained within this PES, as + much of the data in this object is not saved it is is not re-loadable. + Only useful for storing an interpretable (human readable) file + + ----------------------------------------------------------------------- + Arguments: + filename: Name of the file ot save + """ + logger.warning("Saving a PES as a .txt file. Not re-loadable") + arr = np.array(self._energies.to("Ha")) + + if self.ndim > 2: + logger.warning("Flattening PES to save to .txt file") + + np.savetxt(filename, arr.flatten() if self.ndim > 2 else arr) + return None + + def _save_npz(self, filename: str) -> None: + """Save a compressed numpy array, from which a PES can be re-loaded""" + + if not filename.endswith(".npz"): + filename += ".npz" + + # Dictionary of flat arrays in each dimension, and their atom indices + kwds = {f"r{i+1}": np.array(_r) for i, _r in enumerate(self._rs)} + kwds.update( + { + f"a{i+1}": np.array(_r.atom_idxs, dtype=int) + for i, _r in enumerate(self._rs) + } + ) + + np.savez( + filename, + R=self._coordinates, + E=np.array(self._energies.to("Ha")), + **kwds, + ) + + return None + + def _plot_1d(self, interp_factor: int, units_name: str) -> None: + """ + Plot a PES in a single dimension + + ----------------------------------------------------------------------- + Args: + interp_factor: + units_name: + """ + import matplotlib.pyplot as plt + + r_x = self._rs[0] + energies, units = self._energies, energy_unit_from_name(units_name) + energies = units.times * (energies - np.min(energies)) + + plt.scatter( + r_x, + energies, + marker="o", + s=80, # Marker size + alpha=0.8, # Opacity + zorder=10, # Order + facecolors="white", + edgecolors="blue", + ) + + if interp_factor > 0: + from scipy.interpolate import UnivariateSpline + + spline = UnivariateSpline(r_x, energies) + r_x = r_x.smoothed(interp_factor) + energies = spline(r_x) + + # Plot straight lines between the points + plt.plot( + r_x, + energies, + lw=2, + ls="--" if interp_factor > 0 else "-", + c="blue", + alpha=0.9 if interp_factor > 0 else 0.4, + ) + + plt.ylabel(f"$E$ / {units.plot_name}") + plt.xlabel("$r$ / Å") + + return None + + def _plot_2d(self, interp_factor: int, units_name: str) -> None: + """ + Plot the PES in two dimensions + + ----------------------------------------------------------------------- + Arguments: + interp_factor: + units_name: + """ + import matplotlib.pyplot as plt + from mpl_toolkits.mplot3d import Axes3D + from matplotlib.ticker import FormatStrFormatter + + r_x, r_y = self._rs[0], self._rs[1] + energies = self._energies + + if interp_factor > 0: + r_x, r_y = r_x.smoothed(interp_factor), r_y.smoothed(interp_factor) + energies = self._spline_2d()(r_x, r_y) + + # Set up the figure and axes to plot the 3D and projected surfaces on + _ = plt.figure(figsize=(10, 6)) + ax0 = plt.subplot(1, 2, 1, projection=Axes3D.name) + ax1 = plt.subplot(1, 2, 2) + + # Convert the energies in the 2D array from the base Hartree units + units = energy_unit_from_name(units_name) + energies = units.times * (energies - np.min(energies)) + + ax0.plot_surface( + *np.meshgrid(r_x, r_y), energies.T, cmap=plt.get_cmap("plasma") + ) + ax0.set_xlabel("$r_1$ / Å") + ax0.set_ylabel("$r_2$ / Å") + ax0.set_zlabel(f"$E$ / {units.plot_name}") + + im = ax1.imshow( + energies.T, + aspect=(r_x.abs_diff / r_y.abs_diff), + extent=(r_x[0], r_x[-1], r_y[0], r_y[-1]), + origin="lower", + cmap=plt.get_cmap("plasma"), + ) + + contour = ax1.contour( + *np.meshgrid(r_x, r_y), + energies.T, + levels=8, + origin="lower", + colors="k", + linewidths=1, + alpha=0.5, + ) + + plt.clabel(contour, inline=1, fontsize=10, colors="k") + + cbar = plt.colorbar(im, fraction=0.0458, pad=0.04) + cbar.set_label(f"$E$ / {units.plot_name}") + ax1.set_xlabel("$r_1$ / Å") + ax1.set_ylabel("$r_2$ / Å") + ax1.yaxis.set_major_formatter(FormatStrFormatter("%.1f")) + ax1.xaxis.set_major_formatter(FormatStrFormatter("%.1f")) + + return None + + @staticmethod + def _set_mpl_params() -> None: + """Set some matplotlib (mpl) parameters for nice plotting""" + import matplotlib as mpl + + mpl.rcParams["axes.labelsize"] = 15 + mpl.rcParams["lines.linewidth"] = 1 + mpl.rcParams["lines.markersize"] = 5 + mpl.rcParams["xtick.labelsize"] = 14 + mpl.rcParams["ytick.labelsize"] = 14 + mpl.rcParams["xtick.direction"] = "in" + mpl.rcParams["ytick.direction"] = "in" + mpl.rcParams["xtick.top"] = True + mpl.rcParams["ytick.right"] = True + mpl.rcParams["axes.linewidth"] = 1.2 + + return None + + def _spline_2d(self) -> "scipy.interpolate.RectBivariateSpline": + """ + Spline the surface using Scipy. As of scipy v1.7.1 RectBivariateSpline + can only accept monotonically increasing arrays. This function thus + reverses arrays and the energies when appropriate, so the spline can + be fit. + + ----------------------------------------------------------------------- + Returns: + (scipy.interpolate.RectBivariateSpline): Spline + """ + r_x, r_y = self._rs[0], self._rs[1] + + if r_x[0] < r_x[-1] and r_y[0] < r_y[-1]: + # Both x and y are strictly increasing functions + return RectBivariateSpline(r_x, r_y, self._energies) + + if r_x[0] > r_x[-1] and r_y[0] < r_y[-1]: + # Swap x order to get strictly increasing in both dims + return RectBivariateSpline(r_x[::-1], r_y, self._energies[::-1, :]) + + if r_x[0] < r_x[-1] and r_y[0] > r_y[-1]: + # or with y + return RectBivariateSpline(r_x, r_y[::-1], self._energies[:, ::-1]) + + # Reverse both the x and y arrays + return RectBivariateSpline( + r_x[::-1], r_y[::-1], self._energies[::-1, ::-1] + ) + + def __getitem__(self, indices: Union[Tuple, int]): + """ + Get a value on this potential energy surface (PES) at a (set of) + indices + + ----------------------------------------------------------------------- + Arguments: + indices: + + Returns: + (autode.values.Energy): Energy + """ + return Energy(self._energies[indices], units=self._energies.units) + + def __repr__(self): + return f"PES(shape={self.shape})" + + +class _ListDistances1D(list): + def __init__(self, species, rs_dict, allow_rounding=True): + """Construct a list of distance arrays in each dimension""" + super().__init__([]) + + self._species = species + self._allow_rounding_of_stepsize = allow_rounding + + for idxs, value in rs_dict.items(): + self.append(self._distance1d_from_key_val(idxs, value)) + + def _distance1d_from_key_val( + self, + atom_idxs: Tuple[int, int], + value: Union[tuple, np.ndarray], + ) -> "_Distances1D": + """ + From a 'value' determine the initial and final distances to use + + ----------------------------------------------------------------------- + Arguments: + value: Some representation of the final and initial points + + Returns: + (autode.pes.pes_nd._Distances1D): + + Raises: + (ValueError): If the value is not of the correct type + """ + + if isinstance(value, tuple): + return self._distance1d_from_key_val_tuple(atom_idxs, value) # type: ignore + + elif isinstance(value, np.ndarray): + return _Distances1D(value, atom_idxs=atom_idxs) + + else: + raise ValueError( + "Unable to populate distance array for atom " + f"indices {atom_idxs} with: {value}. Must be " + f"either a tuple or numpy array" + ) + + def _distance1d_from_key_val_tuple( + self, + atom_idxs: Tuple[int, int], + value: Union[ + Tuple[float, Union[float, int]], + Tuple[float, float, Union[float, int]], + ], + ): + """ + Determine a array of distances based on a tuple containing either + a final distance or a number of steps to perform. + + ----------------------------------------------------------------------- + Arguments: + atom_idxs: Atom indices + value: + + Returns: + (autode.pes.pes_nd._Distances1D): + """ + + if len(value) == 2: + if self._species is None: + raise ValueError( + "Cannot determine initial point without " + "a defined species" + ) + + # Have a pair, the final distance and either the number of steps + # or the step size + r_init, r_final = self._species.distance(*atom_idxs), value[0] + + elif len(value) == 3: + # A triple also defines the initial distance + r_init, r_final = value[0], value[1] + + else: + raise ValueError( + f"Cannot interpret *{value}* as a final " + f"distance and number of steps or step size" + ) + + if isinstance(value[-1], int): + # Integer values must be a number of steps + num = value[-1] + + elif isinstance(value[-1], float): + num = int(round(abs((r_final - r_init) / value[-1]))) + 1 + + if not self._allow_rounding_of_stepsize: + dr = np.sign(r_final - r_init) * abs(value[-1]) * (num - 1) + r_final = r_init + dr + + else: + raise ValueError(f"Uninterpretable type: {type(value)}") + + if num <= 1: + raise ValueError(f"Unsupported number of steps: {num}") + + return _Distances1D( + np.linspace(r_init, r_final, num=num), atom_idxs=atom_idxs + ) + + def __eq__(self, other): + """Equality of two _ListDistances1D instances""" + return isinstance(other, _ListDistances1D) and super().__eq__(other) + + +class _Distances1D(ValueArray): + implemented_units = [ang] + + def __new__( + cls, + input_array: Union[np.ndarray, Sequence], + atom_idxs: Tuple[int, int], + ): + """ + Create an array of distances in a single dimension, with associated + atom indices, indexed from 0 + + ----------------------------------------------------------------------- + Arguments: + input_array: Array of distances e.g. [1.0, 1.1, 1.2] in Å + + atom_idxs: Indices of the atoms involved in this distance + e.g. (0, 1) + """ + arr = super().__new__(cls, input_array=input_array, units=ang) + + if len(atom_idxs) != 2: + raise ValueError(f"Indices must be a 2-tuple. Had: {atom_idxs}") + + i, j = atom_idxs + if not (isinstance(i, int) and isinstance(j, int)): + raise ValueError(f"Atom indices must be integers. Had: {i}, {j}") + + if i < 0 or j < 0: + raise ValueError(f"Atom indices must be >0: Had {i}, {j}") + + arr.atom_idxs = atom_idxs + return arr + + @property + def min(self) -> float: + return min(self) + + @property + def max(self) -> float: + return max(self) + + @property + def abs_diff(self) -> float: + """ + Absolute difference between the minimum and maximum values on this + array of distances + + ----------------------------------------------------------------------- + Returns: + (float): + """ + return abs(self.max - self.min) + + def smoothed(self, factor: int) -> ValueArray: + """ + Generate a smoothed version of this set of distances, with factor times + more intermediate points + + ----------------------------------------------------------------------- + Arguments: + factor: Factor by which to smooth + + Returns: + (autode.pes.pes_nd._Distances1D): Distance array + """ + + new_arr = np.linspace(self.min, self.max, num=factor * len(self)) + return _Distances1D(input_array=new_arr, atom_idxs=self.atom_idxs) + + def __repr__(self): + return f"Distances(n={len(self)}, [{self.min, self.max}])" diff --git a/autodE/source/autode/pes/reactive.py b/autodE/source/autode/pes/reactive.py new file mode 100644 index 0000000000000000000000000000000000000000..25bfa5b04c74083eebad301ecaf5bde341259aae --- /dev/null +++ b/autodE/source/autode/pes/reactive.py @@ -0,0 +1,405 @@ +""" +'Reactive' potential energy surfaces that have support for saddle point +and transition state guess finding +""" +import numpy as np +from abc import ABC + +from typing import ( + Iterator, + Tuple, + Optional, + Dict, + List, + Union, + Sequence, + TYPE_CHECKING, +) + +from autode.pes.pes_nd import PESnD +from autode.log import logger +from autode.values import Distance +from autode.transition_states.ts_guess import TSguess +from autode.pes.mep import peak_point + +if TYPE_CHECKING: + from autode.species.species import Species + from autode.transition_states.ts_guess import TSguess + + +class ReactivePESnD(PESnD, ABC): + def __init__( + self, + species: "Species", + rs: Dict[Tuple[int, int], Union[Tuple, np.ndarray]], + allow_rounding: bool = True, + ): + """ + Reactive potential energy surface in N-dimensions + + ----------------------------------------------------------------------- + Arguments: + species: Species from which to perform the PES exploration + + rs: Set of atom index pairs defining distances, along with a + representation of what values they should take in the scan + + allow_rounding: Allow rounding of the step size, if required + """ + PESnD.__init__(self, species, rs, allow_rounding) + + self._gradients = np.full( + shape=(*self.shape, self.ndim), fill_value=np.nan + ) + self._hessians = np.full( + shape=(*self.shape, self.ndim, self.ndim), fill_value=np.nan + ) + + def ts_guesses( + self, + product: Optional["Species"] = None, + min_separation: Distance = Distance(0.5, units="Å"), + ) -> Iterator["TSguess"]: + """ + Generate TS guesses from the saddle points in the energy on this + surface. Only those that are seperated by at least min_separation will + be yielded in order of increasing energy. + + ----------------------------------------------------------------------- + Arguments: + product: + + min_separation: Minimum separation on the surface between TS guess + structures. + + Yields: + (autode.transition_states.ts_guess.TSguess): + """ + if not self._has_energy(self.origin): + logger.warning( + "Initial point on the PES not calculated - have " + "no transition state guesses" + ) + return StopIteration + + if product is not None: + # Find the one TS guess by traversing the minimum energy pathway + # from the origin species (reactant state) to a product + try: + yield next(self._mep_ts_guess(product=product)) + + except StopIteration: + logger.warning( + "Found no TS guesses from the minimum energy " "path" + ) + + return StopIteration + + assert self._coordinates is not None, "Must have set coordinates" + assert self._species is not None, "Must have set species" + + yielded_p: List[tuple] = [] + + for idx, point in enumerate(self._sorted_saddle_points()): + if any( + self._distance(p, point) < min_separation for p in yielded_p + ): + continue + + species = self._species.new_species(name=f"ts_guess{idx}") + species.coordinates = self._coordinates[point] + + yielded_p.append(point) + yield TSguess.from_species(species) + + return StopIteration + + def _stationary_points( + self, threshold: float = 0.05, require_energy: bool = True + ) -> Iterator[Tuple]: + """ + Stationary points on the surface, characterised by a zero gradient + vector. On a finite surface the gradient (g) will never truly vanish, + so this function will only return those with |g| less than a threshold + for which all surrounding points have a larger |g|. Is this a + sufficient condition? who knows. + + ----------------------------------------------------------------------- + Arguments: + threshold: Maximum |g| (Ha / Å-1) which to consider a + stationary point. + + Yields: + (tuple(int)): Indices of a stationary point + """ + self._set_gradients() + + for point in self._points(): + if require_energy and not self._has_energy(point): + continue + + grad = self._gradients[point] + + if np.linalg.norm(grad) > threshold: + continue + + if self._is_minimum_in_gradient(point): + yield point + + return StopIteration + + def _saddle_points(self, threshold: float = 0.2) -> Iterator[Tuple]: + """ + Find all the saddle points on the surface + + ----------------------------------------------------------------------- + Arguments: + threshold: Threshold on |λ_i|/max(|λ|), below which they are set + to zero. Used to discard small negative eigenvectors + which would not be negative on a finer surface + + Yields: + (tuple(int)): Indices of a saddle point + """ + for point in self._stationary_points(): + self._set_hessian(point) + + if self._is_saddle_point(point, threshold=threshold): + yield point + + return StopIteration + + def _sorted_saddle_points(self) -> Sequence[Tuple]: + """ + Iterator of saddle points sorted by their energy (low -> high) + + ----------------------------------------------------------------------- + Returns: + (Iterator): + """ + return sorted(self._saddle_points(), key=lambda p: self._energies[p]) + + def _mep_ts_guess(self, product: "Species") -> Iterator["TSguess"]: + """ + Find a TS guess by traversing the minimum energy pathway (MEP) on a + discreet potential energy surface between the initial species + the 'reactant' state, to a new product state. Points are identified + based on graph isomorphisms + + ----------------------------------------------------------------------- + Arguments: + product: Product (hopefully) on the surface + + Yields: + (autode.transition_states.ts_guess.TSguess): TS guess + + Returns: + (StopIteration): If there are no suitable TS guesses + """ + assert self._coordinates is not None, "Must have set coordinates" + assert self._species is not None, "Must have set species" + + reactant = self._species + + if reactant.graph is None or product.graph is None: + logger.warning( + "Products or reactants did not have a defined " + "graph, thus the MEP could not be traversed" + ) + return StopIteration + + product_point = self._point_with_isomorphic_graph_to(product) + + if product_point is None: + logger.warning( + "Could not find any point on the surface that had " + f"the same connectivity as {product}" + ) + return StopIteration + + ts_point = peak_point( + energies=self._energies, point1=self.origin, point2=product_point + ) + + species = self._species.new_species() + species.coordinates = self._coordinates[ts_point] + + yield TSguess.from_species(species) + + def _point_with_isomorphic_graph_to( + self, species: "Species" + ) -> Optional[Tuple]: + """ + Find a point on this surface that is graph-isomorphic to a particular + species. Attempt to return the lowest energy point. + + ----------------------------------------------------------------------- + Arguments: + species: + + Returns: + (tuple(int, ..) | None): + """ + assert self._coordinates is not None, "Must have set coordinates" + assert self._species is not None, "Must have set species" + + isomorphic_points = [] + + for point in self._points(): + mol = self._species.new_species() + mol.coordinates = self._coordinates[point] + mol.reset_graph() + + if mol.has_same_connectivity_as(species): + isomorphic_points.append(point) + + if len(isomorphic_points) == 0: + logger.warning("No isomorphic points found") + return None + + min_idx = np.argmin([self._energies[p] for p in isomorphic_points]) + return isomorphic_points[min_idx] + + def _set_hessian(self, point: Tuple) -> None: + """ + Set the Hessian for a particular point in the surface, evaluated + using finite differences + + ----------------------------------------------------------------------- + Arguments: + point: + """ + hessian = self._hessians[point] + + for i in range(self.ndim): + for j in range(i, self.ndim): + # Point plus 1 (pp) and point minus 1 (pm) in this dimension + pp, pm = self._neighbour(point, j, +1), self._neighbour( + point, j, -1 + ) + + hessian[i, j] = ( + self._gradients[pp][i] - self._gradients[pm][i] + ) / (self._r(pp, j) - self._r(pm, j)) + + # Hessians are symmetric + hessian[j, i] = hessian[i, j] + + return None + + def _set_gradients(self) -> None: + r""" + Set the numerical gradient for each point on the surface, in each + dimension. + + .. math:: + + \frac{\text{d}E}{\text{d}r_i} + = \frac{E(R + \Delta r) - E(R - \Delta r)}{\Delta r} + + where :math:`\Delta r` is the difference between two points on + the surface and :math:`R` is the nuclear positions, :math:`r_i` is a + dimension on the dimension on the surface. If possible, central + differences are used otherwise forwards or backwards finite differences + are used. If neither possible then the gradient is set as np.nan + """ + for p in self._points(): + grad = self._gradients[p] # Gradient with shape: (ndim,) + + if not self._has_energy(p): + logger.warning( + f"Cannot set the gradient for point: {p} as it " + "did not have an energy" + ) + grad.fill(np.nan) + continue + + for n in range(self.ndim): + pm, pp = self._neighbour(p, n, +1), self._neighbour(p, n, -1) + + if not self._has_energy(pm): + pm = p + + if not self._has_energy(pp): + pp = p + + if pm == pp: + logger.warning( + "Cannot determine gradient. Neither " + "neighbour had an energy" + ) + grad[n] = np.nan + continue + + grad[n] = (self._energies[pp] - self._energies[pm]) / ( + self._r(pp, n) - self._r(pm, n) + ) + + return None + + def _is_minimum_in_gradient(self, point: Tuple) -> bool: + """ + Is a particular point surrounded by points with larger gradients? + Only checks ±1 in each dimension, NOT combinations (i.e. diagonals) + and uses the norm of the gradient (|g|). + + ----------------------------------------------------------------------- + Arguments: + point: + + Returns: + (bool): + """ + norm_grad = np.linalg.norm(self._gradients[point]) + + for n in range(self.ndim): + pm, pp = self._neighbour(point, n, +1), self._neighbour( + point, n, -1 + ) + + if not (self._is_contained(pm) and self._is_contained(pp)): + return False + + for grad in (self._gradients[pm], self._gradients[pp]): + if np.any(np.isnan(grad)): + # Cannot determine if it is a minimum with undefined NN + return False + + if np.linalg.norm(grad) < norm_grad: + return False + + return True + + def _is_saddle_point(self, point: Tuple, threshold: float = 0.2) -> bool: + """ + Is this point in the surface a saddle point. In the ideal case of a + continuous surface a saddle point has a single negative eigenvalue + of the Hessian matrix. However, + + ----------------------------------------------------------------------- + Arguments: + point: Point on the surface. Hessian must be set here + + threshold: Threshold on |λ_i|/|max(λ)|, below which they are set + to zero. Used to discard spurious imaginary modes + + Returns: + (bool): If this point is a saddle point, to within a threshold + """ + + eigenvals = np.linalg.eigvals(self._hessians[point]) + + tol = np.max(np.abs(eigenvals)) * threshold + + # Eigenvalues (λ) with an absolute value less than the tolerance are 0 + eigenvals[np.abs(eigenvals) < tol] = 0.0 + + logger.warning(f"{point} had eigenvalues: {eigenvals}") + return len([x for x in eigenvals if x < 0]) == 1 + + @property + def _tensors(self) -> Sequence[np.ndarray]: + """Tensors in this PES""" + attrs = ("_energies", "_gradients", "_hessians") + + return [getattr(self, a) for a in attrs if hasattr(self, a)] diff --git a/autodE/source/autode/pes/relaxed.py b/autodE/source/autode/pes/relaxed.py new file mode 100644 index 0000000000000000000000000000000000000000..63d5ca515aba62497c3b5c08dfb8faa003f07df2 --- /dev/null +++ b/autodE/source/autode/pes/relaxed.py @@ -0,0 +1,208 @@ +import numpy as np +import itertools as it + +from typing import Tuple, List, Type, Iterator, TYPE_CHECKING + +from autode.log import logger +from autode.utils import hashable, ProcessPool +from autode.pes.reactive import ReactivePESnD +from autode.constraints import DistanceConstraints +from autode.calculations import Calculation +from autode.exceptions import CalculationException + +if TYPE_CHECKING: + from autode.species.species import Species + from autode.wrappers.keywords import Keywords + from autode.wrappers.methods import Method + + +class RelaxedPESnD(ReactivePESnD): + """Potential energy surface over a set of distances, where all other + degrees of freedom are minimised""" + + def _calculate(self) -> None: + """ + Calculate the n-dimensional surface + """ + assert self._coordinates is not None, "Coordinates must be set" + + for points in self._points_generator(): + n_cores_pp = max(self._n_cores // len(points), 1) + logger.info( + f"Calculating tranche {points} on the surface, using " + f"{n_cores_pp} cores per process" + ) + + with ProcessPool(max_workers=self._n_cores) as pool: + func = hashable("_single_energy_coordinates", self) + + jobs = [ + pool.submit( + func, self._species_at(point), n_cores=n_cores_pp + ) + for point in points + ] + + for i, point in enumerate(points): + ( + self._energies[point], + self._coordinates[point], + ) = jobs[i].result() + + return None + + @property + def _default_keyword_type(self) -> Type["Keywords"]: + from autode.wrappers.keywords import OptKeywords + + return OptKeywords + + def _species_at(self, point: Tuple) -> "Species": + """ + Generate a species on the PES at a defined point. Attributes are + obtained from the internal species (molecule at the origin in the PES) + while the coordinates are set from the closest point and the + constraints defined by the point. + + ----------------------------------------------------------------------- + Arguments: + point: Point at which to generate the species e.g. (0,) in a 1D + surface or (1, 2 3) for a 3D surface + + Returns: + (autode.species.Species): Species + """ + assert self._species + + species = self._species.new_species(name=self._point_name(point)) + species.coordinates = self._closest_coordinates(point) + species.constraints.distance = self._constraints(point) + + return species + + def _single_energy_coordinates( + self, species: "Species", **kwargs + ) -> Tuple[float, np.ndarray]: + """ + Calculate a single energy and set of coordinates on this surface + + ----------------------------------------------------------------------- + Arguments: + species: Species on which to perform a constrained minimisation + + Keyword Arguments: + n_cores: Number of cores to use for the calculation, if left + unassigned then use self._n_cores + """ + assert self._keywords is not None and self._method is not None + + const_opt = Calculation( + name=species.name, + molecule=species, + method=self._method, + n_cores=kwargs.get("n_cores", self._n_cores), + keywords=self._keywords, + ) + + try: + species.optimise(method=self._method, calc=const_opt) + assert species.energy is not None + return float(species.energy), np.array(species.coordinates) + + except (CalculationException, ValueError, TypeError, AssertionError): + logger.error(f"Optimisation failed for: {species.name}") + return np.nan, np.zeros(shape=(species.n_atoms, 3)) + + def _default_keywords(self, method: "Method") -> "Keywords": + """Default keywords""" + assert ( + method.keywords.opt is not None + ), "Method must have optimisation kwds" + return method.keywords.opt + + def _closest_coordinates(self, point: Tuple) -> np.ndarray: + """ + From a point in the PES defined by its indices obtain the closest set + of coordinates, which to use as a starting guess for the constrained + optimisation of this point. The closest point is obtained by computing + all distances to the n^th nearest neighbours that also has an energy. + + ----------------------------------------------------------------------- + Arguments: + point: Tuple of indicies in the surface e.g. (0,) in a 1D surface + or (0, 1, 2) in a 3D surface + + Returns: + (np.ndarray): Coordinates. shape = (n_atoms, 3) + """ + assert self._coordinates is not None, "Must have set coordinates" + + if point == self.origin: + return self._coordinates[self.origin] + + # Increment out from the nearest neighbours ('distance' 1) + for n in range(1, max(self.shape)): + # Construct a ∆-point tuple, which can be added to the current + # point to generate one close by, which may have an energy and thus + # should be selected + for d_point in it.product(range(-n, n + 1), repeat=self.ndim): + close_point = tuple(np.array(point) + np.array(d_point)) + + if not self._is_contained(close_point): + continue + + if self._has_energy(close_point): + return self._coordinates[close_point] + + raise RuntimeError( + "Failed to find coordinates with an associated " + f"energy close to point {point} in the PES" + ) + + def _constraints(self, point: Tuple) -> DistanceConstraints: + """ + Construct the distance constraints required for a particular point + on the PES + + ----------------------------------------------------------------------- + Arguments: + point: Indices of a point on the surface + + Returns: + (dict): Distance constraints + """ + if not self._is_contained(point): + raise ValueError( + f"Cannot determine constraints for a point: " + f"{point} in a {self.ndim}D-PES" + ) + + return DistanceConstraints( + {r.atom_idxs: r[idx] for r, idx in zip(self._rs, point)} + ) + + def _points_generator(self) -> Iterator[List[Tuple]]: + """ + Yield points on this surface that sum to the same total, thus are + close and should be calculated in a group, in parallel. This *should* + provide the most efficient calculation decomposition on the surface + + ----------------------------------------------------------------------- + Yields: + (list(tuple(int))): + """ + all_points = list(self._points()) + + for i in range(0, sum(self.shape)): + points: List[tuple] = [] + while all_points: + # Next point is the next step in the grid + if len(points) > 0 and sum(all_points[0]) > i: + break + + points.insert(0, all_points.pop(0)) + + if len(points) > 0: + yield points + + return StopIteration diff --git a/autodE/source/autode/pes/unrelaxed.py b/autodE/source/autode/pes/unrelaxed.py new file mode 100644 index 0000000000000000000000000000000000000000..517e85e43d040dc444adc3b9bec16cc8cde44d9f --- /dev/null +++ b/autodE/source/autode/pes/unrelaxed.py @@ -0,0 +1,148 @@ +"""Unrelaxed potential energy surfaces""" +import numpy as np +from typing import Tuple, Type, TYPE_CHECKING + +from autode.pes.reactive import ReactivePESnD +from autode.utils import hashable, ProcessPool +from autode.log import logger +from autode.mol_graphs import split_mol_across_bond +from autode.exceptions import CalculationException + + +if TYPE_CHECKING: + from autode.species.species import Species + from autode.wrappers.keywords import Keywords + from autode.wrappers.methods import Method + + +class UnRelaxedPES1D(ReactivePESnD): + """1D potential energy surface without minimising other degrees of freedom. + Only supports over bonds""" + + def _calculate(self) -> None: + """Calculate this surface, in the maximally parallel way""" + self._check() + points = list(self._points()) + + # Number of cores per-process depends on the number of points in the + # PES. The number of workers executing will be at most len(points) + n_cores_pp = max(self._n_cores // len(points), 1) + + with ProcessPool(max_workers=self._n_cores) as pool: + results = [ + pool.submit( + hashable("_single_energy", self), + self._species_at(p), + n_cores_pp, + ) + for p in points + ] + + for i, p in enumerate(points): + self._energies[p] = results[i].result() + + return None + + @property + def _default_keyword_type(self) -> Type["Keywords"]: + from autode.wrappers.keywords import SinglePointKeywords + + return SinglePointKeywords + + def _species_at(self, point: Tuple) -> "Species": + """ + Shift this structure to a point in the surface + + ----------------------------------------------------------------------- + Arguments: + point: Point on the surface + + Returns: + (autode.species.species.Species): New species + """ + assert self._coordinates is not None, "Must have set coordinates array" + assert self._species, "Must have a base species" + + species = self._species.new_species(name=self._point_name(point)) + i, j = self._rs[0].atom_idxs + + shift_idxs, _ = split_mol_across_bond(species.graph, bond=(i, j)) + + a = i if i in shift_idxs else j + b = j if i == a else i + + coords = np.array(species.coordinates, copy=True) + required_r = self._r(point=point, dim=0) + + coords[shift_idxs] += ( + required_r - species.distance(i, j) + ) * species.atoms.nvector(b, a) + + self._coordinates[point] = coords + species.coordinates = coords + + return species + + def _check(self) -> None: + """Check that some attributes have required values""" + + if self.ndim != 1: + raise NotImplementedError( + "Cannot calculate an unrelaxed surface " + "for >1 dimension surfaces" + ) + + assert ( + self._species and self._species.graph + ), "Unrelaxed PES scan must have a species with a graph" + + atom_idxs = self._rs[0].atom_idxs + if atom_idxs not in self._species.graph.edges: + raise ValueError( + f"Unrelaxed PESs must be over a bond {atom_idxs} " + f"was not in the list of bonds" + ) + + return None + + def _default_keywords(self, method: "Method") -> "Keywords": + """ + Default keywords for an unrelaxed scan that uses single point + evaluations is + + ----------------------------------------------------------------------- + Arguments: + method: + + Returns: + (autode.wrappers.keywords.Keywords): + """ + assert ( + method.keywords.sp is not None + ), "Must have single point energy kwds" + return method.keywords.sp + + def _single_energy(self, species: "Species", n_cores: int) -> float: + """ + Evaluate the energy using a single point calculation + + ----------------------------------------------------------------------- + Arguments: + species: Species on the surface + + n_cores: Number of cores to use + + Returns: + (float): Energy in Ha + """ + + try: + species.single_point( + method=self._method, keywords=self._keywords, n_cores=n_cores + ) + assert species.energy is not None + return float(species.energy) + + except (CalculationException, ValueError, TypeError, AssertionError): + logger.error(f"Single point failed for: {species.name}") + return np.nan diff --git a/autodE/source/autode/plotting.py b/autodE/source/autode/plotting.py new file mode 100644 index 0000000000000000000000000000000000000000..7095d25ca487f2cabb7ad4f735a07da23347f99c --- /dev/null +++ b/autodE/source/autode/plotting.py @@ -0,0 +1,518 @@ +import os +import numpy as np +from typing import Sequence, Union, TYPE_CHECKING, List, Optional, Any, Tuple +from scipy import interpolate + +from autode.values import Energy +from autode.exceptions import CouldNotPlotSmoothProfile +from scipy.optimize import minimize +from autode.config import Config +from autode.units import energy_unit_from_name +from autode.log import logger + +if TYPE_CHECKING: + from autode.reactions.reaction import Reaction + from autode.units import Unit + from autode.opt.optimisers.base import OptimiserHistory + from matplotlib.figure import Figure + + +def save_plot(figure: "Figure", filename: str, **kwargs): + """ + Save a pyplot figure + + Args: + figure (matplotlib.figure.Figure): The matplotlib figure object + filename (str): Name of the file to plot + **kwargs : Other keyword arguments for matplotlib which + are passed onto figure.savefig() + """ + import matplotlib.pyplot as plt + + if os.path.exists(filename): + logger.warning("Plot already exists. Overriding..") + os.remove(filename) + + dpi = 400 if Config.high_quality_plots else 100 + figure.savefig(filename, dpi=dpi, **kwargs) + plt.close(figure) + + return None + + +def plot_reaction_profile( + reactions: Sequence["Reaction"], + units: Union["Unit", str], + name: str, + free_energy: bool = False, + enthalpy: bool = False, +) -> None: + """ + For a set of reactions plot the reaction profile using matplotlib + + --------------------------------------------------------------------------- + Arguments: + reactions (list((autode.reaction.Reaction)): + + units (autode.units.Units | str): + + name (str): + + free_energy (bool): Plot the free energy profile (G) + + enthalpy (bool): Plot the enthalpic profile (H) + """ + import matplotlib.pyplot as plt + + logger.info("Plotting reaction profile") + + if free_energy and enthalpy: + raise AssertionError("Cannot plot a profile in both G and H") + + if isinstance(units, str): + units = energy_unit_from_name(name=units) + + fig, ax = plt.subplots() + + # Get the energies for the reaction profile (y values) plotted against the + # reaction coordinate (zi_s) + energies = calculate_reaction_profile_energies( + reactions, units=units, free_energy=free_energy, enthalpy=enthalpy + ) + zi_s = np.array(range(len(energies))) + + try: + plot_smooth_profile(zi_s, energies, ax=ax) + + except CouldNotPlotSmoothProfile: + plot_points(zi_s, energies, ax=ax) + + ec = "E" + if free_energy: + ec = "G" + elif enthalpy: + ec = "H" + + plt.ylabel(f"∆${ec}$ / {units.plot_name}", fontsize=12) + plt.xlabel("Reaction coordinate") + + energy_values = [energy for energy in energies] + max_delta = max(energy_values) - min(energy_values) + plt.ylim( + min(energy_values) - 0.09 * max_delta, + max(energy_values) + 0.09 * max_delta, + ) + plt.xticks([]) + plt.subplots_adjust(top=0.95, right=0.95) + fig.text( + 0.1, + 0.05, + get_reaction_profile_warnings(reactions), + ha="left", + fontsize=6, + wrap=True, + ) + + prefix = "" if name == "reaction" else f"{name}_" + return save_plot(fig, filename=f"{prefix}reaction_profile.pdf") + + +def plot_smooth_profile(zi_s, energies, ax): + """ + Plot a smooth reaction profile by spline interpolation and finding the + stationary points. This will not afford the correct number of stationary + points for some energy arrays, so raise an exception if it fails + + --------------------------------------------------------------------------- + Arguments: + zi_s (np.ndarray): Estimate of reaction coordinate points + + energies (list(autode.plotting.Energy)): len(energies) = len(zi_s) + + ax (matplotlib.axes.Axes): + """ + + # Minimise a set of spline points so the stationary points have y values + # given in the energies array + energies_arr = np.array([energy for energy in energies], dtype="f") + result = minimize( + error_on_stationary_points, + x0=energies_arr, + args=(energies_arr,), + method="BFGS", + tol=0.1, + ) + + # Use the optimised values to construct a spline function that will be + # plotted + optimised_spline = interpolate.CubicSpline( + zi_s, result.x, bc_type="clamped" + ) + + # Create more zi values from slightly before the minimum to slightly after + # the maximum + fine_zi_s = np.linspace(min(zi_s) - 0.2, max(zi_s) + 0.2, num=500) + + # The new zi values are the stationary points of the optimised function + zi_s = get_stationary_points(fine_zi_s, optimised_spline.derivative()) + + if len(zi_s) != len(energies): + raise CouldNotPlotSmoothProfile + + # Plot the function + ax.plot(fine_zi_s, optimised_spline(fine_zi_s), c="k") + ax.scatter(zi_s, optimised_spline(zi_s), c="b", zorder=10) + + # Annotate the plot with the relative energies + max_delta = max(energies) - min(energies) + + for i, energy in enumerate(optimised_spline(zi_s)): + if energies[i].is_estimated: + # Don't add estimated energies + continue + + # Shift the minima labels (even points) below the point and the + # transition state labels above the point + shift = -0.07 * max_delta if i % 2 == 0 else 0.03 * max_delta + + ax.annotate( + f"{energy:.1f}", + (zi_s[i], energy + shift), + fontsize=12, + ha="center", + ) + + return None + + +def plot_points(zi_s, energies, ax): + """ + Plot a reaction profile just adding the points to the graph + + --------------------------------------------------------------------------- + Arguments: + zi_s (np.ndarray): Estimate of reaction coordinate points + + energies (list(autode.plotting.Energy)): len(energies) = len(zi_s) + + ax (matplotlib.axes.Axes): + """ + energies_arr = np.array([energy for energy in energies]) + + ax.plot(zi_s, energies_arr, ls="--", c="k", marker="o") + + # Annotate the plot with the relative energies + for i, energy in enumerate(energies): + if hasattr(energy, "estimated") and energy.is_estimated: + # Don't add estimated energies + continue + + ax.annotate( + f"{np.round(energies_arr[i], 1)}", + (zi_s[i], energies_arr[i] + 0.7), + fontsize=12, + ha="center", + ) + return None + + +def get_reaction_profile_warnings(reactions): + """ + Get a string of warnings for a reaction + + --------------------------------------------------------------------------- + Arguments: + reactions (list(autode.reaction.Reaction)): + + Returns: + (str): List of warnings to annotate the plot with + """ + logger.info("Getting warnings for reaction profile") + warnings = "" + + for reaction in reactions: + if reaction.delta("E") is None: + warnings += ( + f"∆Er not calculated for {reaction.name}, " + f"∆Er = 0 assumed. " + ) + + de_ts = reaction.delta("E‡") + if de_ts is None or (de_ts is not None and de_ts.is_estimated): + warnings += ( + f"∆E‡ not calculated for {reaction.name}, " + f"barrierless reaction assumed. " + ) + + if reaction.ts is not None: + if reaction.ts.has_imaginary_frequencies: + n_imag_freqs = len(reaction.ts.imaginary_frequencies) + + if n_imag_freqs != 1: + warnings += ( + f"TS for {reaction.name} has {n_imag_freqs} " + f"imaginary frequencies. " + ) + + warnings += reaction.ts.warnings + + # If no strings were added then there are no warnings + if len(warnings) == 0: + warnings = "None" + + return f"WARNINGS: {warnings}" + + +def calculate_reaction_profile_energies( + reactions, units, free_energy=False, enthalpy=False +): + """ + Calculate a list of energies comprising the reaction profile + + --------------------------------------------------------------------------- + Arguments: + reactions (list(autode.reaction.Reaction)): + + units (autode.units.Units): + + Keyword Arguments: + free_energy (bool): Calculate ∆Gs + + enthalpy (bool): Calculate ∆Hs + + Returns: + (np.ndarray(autode.plotting.Energy)) + """ + # Populate a list of reaction relative energies + # [reactants -> TS -> products], all floats + reaction_energies = [] + energy_type = "H" if enthalpy else ("G" if free_energy else "E") + + for reaction in reactions: + de = reaction.delta(energy_type) + + # If ∆Er cannot be calculated then assume isoenergetic and add a + # warning to the plot + if de is None: + de = Energy(0.0, estimated=True) + + de_ts = reaction.delta(f"{energy_type}‡") + + # If there is no ∆E then de_ts could be None. Use the Effective free + # energy barrier of 4.35 kcal mol-1 + if de_ts is None: + de_ts = Energy(0.00694, units="Ha", estimated=True) + + reaction_energies.append([Energy(0.0), de_ts, de]) + + # Construct the full list of energies, referenced to the first set of + # reactants + energies = reaction_energies[0] + + for i in range(1, len(reaction_energies)): + # Add the energies from the next TS and the next product reaction_ + # energies[i][0] == energies[-1 + energies += [ + reaction_energies[i][1] + energies[-1], + reaction_energies[i][2] + energies[-1], + ] + + return [energy * units.times for energy in energies] + + +def get_stationary_points(xs, dydx): + """ + Compute the productive of the derivative at points x(i-1) and x(i) which + is negative if there is a point x(k) + between x(i-1) and x(i) that has dy/dx|x(k) = 0 + + --------------------------------------------------------------------------- + Arguments: + xs (np.ndarray): + + dydx (function): + """ + stationary_points = [] + + for i in range(1, len(xs) - 1): + if dydx(xs[i - 1]) * dydx(xs[i]) < 0: + stationary_points.append(xs[i]) + + return stationary_points + + +def error_on_stationary_points(x, energies): + """ + Calculate the difference between the stationary points of an interpolated + function and those observed (given in the energies array). Example:: + + | . + E |. / | The points indicate the true stationary points + | |_/ |. + |_____________ + zi + + --------------------------------------------------------------------------- + Arguments: + x (np.ndarray): Points that will be splined that generate stationary + points that ≈ energies + + energies (np.ndarray): Observed stationary points + + Returns: + (float): A measure of the error + """ + # Generate a list of reaction coordinate points - arbitrary units so + # integers are fine + zi_s = np.array(range(len(x))) + + # Spline the energies to get a function that has stationary points + spline = interpolate.CubicSpline(zi_s, x, bc_type="clamped") + + # Calculate the energy values at the stationary points of the function with + # a fine-ish spacing that extrapolates + # slightly + fine_zi_s = np.linspace(min(zi_s) - 0.2, max(zi_s) + 0.2, num=500) + stationary_points = get_stationary_points( + xs=fine_zi_s, dydx=spline.derivative() + ) + + if len(stationary_points) != len(energies): + # TODO make this smooth somehow + # Energy penalty for not having the required number of + return 10 * np.abs(len(energies) - len(stationary_points)) + + energies_at_stationary_points = [spline(zi) for zi in stationary_points] + + # Return the error as the sum squared difference between the required and + # the observed stationary point energies + energy_difference = energies - np.array(energies_at_stationary_points) + + return np.sum(np.square(energy_difference)) + + +def plot_optimiser_profile( + history: "OptimiserHistory", + plot_energy: bool, + plot_rms_grad: bool, + filename: str, +): + """ + Plot the energy and RMS gradient profile from an optimiser history. + Skips plotting of points where energy/grad is not available + + ------------------------------------------------------------------------- + Args: + history (OptimiserHistory): History (list) of coordinate objects + plot_energy (bool): Whether to plot energy or not + plot_rms_grad (bool): Whether to plot rms grad or not + filename (str): Name of plotted file + """ + if not (plot_energy or plot_rms_grad): + logger.error( + "Must plot either energies or RMS gradients for an" + " optimiser profile" + ) + return None + + import matplotlib.pyplot as plt + from matplotlib.ticker import MaxNLocator + + x_axis = [i + 1 for i in range(len(history))] # starts at 0 + energies = [] + rms_grads = [] + for coord in history: + if coord.e is not None: + energies.append(coord.e.to("Ha")) + else: + energies.append(np.nan) + + if coord.g is not None: + rms = np.sqrt(np.average(np.square(coord.to("cart").g))) + rms_grads.append(rms) + else: + rms_grads.append(np.nan) + + fig, ax = plt.subplots() + + if plot_energy: + ax.plot( + x_axis, energies, "o-", color="C0", label="Electronic energy" + ) # blue + ax.set_xlabel("Optimiser step") + ax.set_ylabel("Electronic energy / Ha") + + ax.set_xlim(left=0.5) + ax.xaxis.set_major_locator( + MaxNLocator(nbins="auto", steps=[1, 2, 2.5, 5, 10], integer=True) + ) + + if plot_rms_grad: + # plot on a different axis if both are present + ax2 = ax.twinx() if plot_energy else ax + ax2.plot( + x_axis, rms_grads, "o:", color="C3", label="RMS gradient" + ) # red + ax2.set_ylabel("RMS of gradient / Ha(Å)^-1") + + fig.legend( + loc="upper right", bbox_to_anchor=(1, 1), bbox_transform=ax.transAxes + ) + # bbox_inches="tight" uses tight bounding box, which prevents labels cutting out + save_plot(fig, filename, bbox_inches="tight") + + +def plot_bracket_method_energy_profile( + filename: str, + left_points: List[Tuple[int, Energy]], + cineb_point: Optional[tuple], + right_points: List[Tuple[int, Energy]], + x_title: str, +) -> None: + """ + Plot the energy profile from a bracketing method run, showing the + points from left and right image and final CI-NEB (if done), in + different colours. Energies should be in kcal/mol. + + Args: + filename (str): Filename with extension + left_points (list[tuple]): List of tuples containing position and + energies from left image + cineb_point (tuple|None): Tuple with position and energy for CI-NEB peak + right_points (list[tuple]): List of tuples containing position and + energies from right image + x_title (str): Title of the x-axis + """ + import matplotlib.pyplot as plt + + fig, ax = plt.subplots() + + # the data should be cast into kcal/mol + kcalmol = energy_unit_from_name("kcalmol") + + left_x = [point[0] for point in left_points] + left_y = [point[1].to(kcalmol) for point in left_points] + ax.plot(left_x, left_y, "bo-", label="initial image") + + right_x = [point[0] for point in right_points] + right_y = [point[1].to(kcalmol) for point in right_points] + ax.plot(right_x, right_y, "go-", label="final image") + + # plot the CI-NEB point and join it to the ends + if cineb_point is not None: + ax.plot( + [left_x[-1], cineb_point[0], right_x[0]], + [ + left_y[-1].to(kcalmol), + cineb_point[1].to(kcalmol), + right_y[0].to(kcalmol), + ], + "ro-", + label="CI-NEB", + ) + + ax.set_xlabel(x_title) + ax.set_ylabel(f"Electronic energy / {kcalmol.plot_name}") + ax.legend() + save_plot(fig, filename=filename) + return None diff --git a/autodE/source/autode/point_charges.py b/autodE/source/autode/point_charges.py new file mode 100644 index 0000000000000000000000000000000000000000..b57a52cbd27383db335c0f39a5b09a03509cfe50 --- /dev/null +++ b/autodE/source/autode/point_charges.py @@ -0,0 +1,37 @@ +from typing import Sequence, Optional + +from autode.atoms import DummyAtom +from autode.values import Coordinate + + +class PointCharge(DummyAtom): + def __init__( + self, + charge: float, + x: float = 0.0, + y: float = 0.0, + z: float = 0.0, + coord: Optional[Sequence] = None, + ): + """ + Point charge + + ----------------------------------------------------------------------- + Arguments: + charge (float): Charge in units of e + + Keyword Arguments: + x (float): x coordinate (Å) + y (float): y coordinate (Å) + z (float): z coordinate (Å) + coord (np.ndarray | None): Length 3 array of x, y, z coordinates + or None + """ + super().__init__(x, y, z) + + self.charge = float(charge) + + if coord is not None: + assert len(coord) == 3, "Coordinate much have 3 components: x,y,z" + x, y, z = coord[0], coord[1], coord[2] + self._coord = Coordinate(float(x), float(y), float(z)) diff --git a/autodE/source/autode/reactions/__init__.py b/autodE/source/autode/reactions/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..5765837d8a417c6a6493e5d15a88dd143a555738 --- /dev/null +++ b/autodE/source/autode/reactions/__init__.py @@ -0,0 +1,5 @@ +from autode.reactions.reaction import Reaction +from autode.reactions.multistep import MultiStepReaction + + +__all__ = ["Reaction", "MultiStepReaction"] diff --git a/autodE/source/autode/reactions/multistep.py b/autodE/source/autode/reactions/multistep.py new file mode 100644 index 0000000000000000000000000000000000000000..ac58bb9de9dfe01d213aba5235e2e1dc26b12a04 --- /dev/null +++ b/autodE/source/autode/reactions/multistep.py @@ -0,0 +1,224 @@ +from typing import Union, TYPE_CHECKING + +from autode.plotting import plot_reaction_profile +from autode.methods import get_hmethod +from autode.config import Config +from autode.reactions.reaction import Reaction +from autode.log import logger +from autode.utils import work_in + +if TYPE_CHECKING: + from autode.units import Unit + from autode.species.molecule import Molecule + + +class MultiStepReaction: + def __init__(self, *args: "Reaction", name: str = "reaction"): + """ + Reaction with multiple steps. + + ----------------------------------------------------------------------- + Arguments: + *args: Set of reactions to calculate the reaction profile for + """ + self.name = str(name) + self.reactions = self._checked_are_reactions(args) + + def calculate_reaction_profile( + self, + units: Union["Unit", str] = "kcal mol-1", + ) -> None: + """ + Calculate a multistep reaction profile using the products of step 1 + as the reactants of step 2 etc. Example + + .. code-block:: + + >>> import autode as ade + >>> ade.Config.n_cores = 16 + + >>> ade.Config.ORCA.keywords.set_opt_basis_set('ma-def2-SVP') + >>> ade.Config.ORCA.keywords.sp.basis_set = 'ma-def2-TZVP' + + >>> step1 = ade.Reaction('CC(F)=O.C[O-]>>CC([O-])(F)OC', solvent_name='water') + >>> step2 = ade.Reaction('CC([O-])(F)OC>>CC(OC)=O.[F-]', solvent_name='water') + + >>> rxn = ade.MultiStepReaction(step1, step2) + >>> rxn.calculate_reaction_profile() + """ + logger.info(f"Calculating an {self.n_steps} step reaction profile") + hmethod = get_hmethod() if Config.hmethod_conformers else None + + @work_in(self.name) + def calculate(idx): + rxn = self.reactions[idx] + + if idx == 0: # First reaction + rxn.find_lowest_energy_conformers() + rxn.optimise_reacs_prods() + + else: # Subsequent reactions have already calculated components + self._set_reactants_from_previous_products(idx) + + for prod in rxn.prods: + prod.find_lowest_energy_conformer(hmethod=hmethod) + prod.optimise(method=hmethod) + + rxn.locate_transition_state() + rxn.find_lowest_energy_ts_conformer() + rxn.calculate_single_points() + + return None + + for i, reaction in enumerate(self.reactions): + reaction.name = f"{self.name}_step{i}" + calculate(idx=i) + + self._balance() + plot_reaction_profile(self.reactions, units=units, name=self.name) + return None + + @property + def n_steps(self) -> int: + """Number of steps in this multistep reaction""" + return len(self.reactions) + + def _balance(self) -> None: + """ + Balance reactants and products in each reaction such that the total + number of atoms is conserved throughout the reaction. Allows for + multistep reactions where molecules are added e.g. catalytic cycles + """ + + for i in range(self.n_steps - 1): + j = i + 1 # Index of the next step in the sequence of reactions + + if self.reactions[j].has_identical_composition_as( + self.reactions[i] + ): + continue + + if self._adds_molecule(i, j): + self._add_mol_to_reacs_prods( + mol=self._added_molecule(i, j), to_idx=j + ) + + elif self._adds_molecule(j, i): + self._add_mol_to_reacs_prods( + mol=self._added_molecule(j, i), from_idx=j + ) + + else: + raise RuntimeError( + "Failed to balance the multistep reaction. " + "No reactants present as products of a " + "previous step." + ) + + return None + + def _adds_molecule(self, step_idx: int, next_step_idx: int) -> bool: + """Does reaction 2 have more molecules(atoms) than reaction 1""" + + def total_n_atoms(rxn): + return sum(m.n_atoms for m in rxn.reacs) + + return total_n_atoms(self.reactions[step_idx]) < total_n_atoms( + self.reactions[next_step_idx] + ) + + def _add_mol_to_reacs_prods(self, mol, from_idx=0, to_idx=None): + """Add a molecule to both reactants and products""" + if to_idx is None: + to_idx = self.n_steps + + for reaction in self.reactions[from_idx:to_idx]: + reaction.reacs.append(mol.to_reactant()) + reaction.prods.append(mol.to_product()) + + return None + + def _added_molecule(self, step_idx: int, next_step_idx: int) -> "Molecule": + r""" + Extract the added molecule going from a step to the next. For example:: + + A + B -> C + C + D -> E + + the added molecule would be D. The balanced sequence is then: + A + B + D -> C + D -> E + ----------------------------------------------------------------------- + Arguments: + step_idx: + next_step_idx: + + Returns: + (autode.species.molecule.Molecule): + + Raises: + (RuntimeError): + """ + prods = self.reactions[step_idx].prods + + for mol in self.reactions[next_step_idx].reacs: + if any(p.has_identical_composition_as(mol) for p in prods): + continue + + return mol + + raise RuntimeError("Failed to find the added molecule") + + def _set_reactants_from_previous_products(self, step_idx: int) -> None: + """ + Given a reaction step use the previous reactants + + ----------------------------------------------------------------------- + Arguments: + step_idx: Index of the step (indexed from 0) + + Raises: + (ValueError): For invalid arguments + + (RuntimeError): If setting is impossible because the multistep + reaction is not sequential + """ + logger.info("Setting reactants from previous steps products") + + if step_idx == 0: + raise ValueError( + "Cannot set the products of step -1 as the " + "reactants of step 0. No step -1!" + ) + + prev_reaction = self.reactions[step_idx - 1] + + for reactant in self.reactions[step_idx].reacs: + try: + matching_prod = next( + p + for p in prev_reaction.prods + if reactant.has_identical_composition_as(p) + ) + except StopIteration: + raise RuntimeError( + "Failed to find a matching product for " + f"{reactant} in {prev_reaction}" + ) + + reactant.atoms = matching_prod.atoms.copy() + reactant.conformers = matching_prod.conformers.copy() + reactant.energies = matching_prod.energies.copy() + + return None + + @staticmethod + def _checked_are_reactions(args): + """Check that all objects in a list are autodE Reaction instances""" + + if not all(isinstance(item, Reaction) for item in args): + raise ValueError( + "Cannot initialise a multistep reaction from: " + f"{args}. Must all be autode Reaction instances" + ) + + return args diff --git a/autodE/source/autode/reactions/reaction.py b/autodE/source/autode/reactions/reaction.py new file mode 100644 index 0000000000000000000000000000000000000000..d0ae649df34c90b9ebec98829266730f5ebf1d5f --- /dev/null +++ b/autodE/source/autode/reactions/reaction.py @@ -0,0 +1,909 @@ +import base64 +import hashlib +import pickle + +from typing import Union, Optional, List, Generator, TYPE_CHECKING +from datetime import date + +from autode.config import Config +from autode.solvent.solvents import get_solvent +from autode.transition_states.locate_tss import find_tss +from autode.transition_states import TransitionState, TransitionStates +from autode.exceptions import UnbalancedReaction, SolventsDontMatch +from autode.log import logger +from autode.methods import get_hmethod +from autode.species.complex import ReactantComplex, ProductComplex +from autode.species.molecule import Reactant, Product +from autode.plotting import plot_reaction_profile +from autode.values import ( + Energy, + PotentialEnergy, + Enthalpy, + FreeEnergy, + Temperature, +) +from autode.utils import ( + work_in, + requires_hl_level_methods, + checkpoint_rxn_profile_step, +) +from autode.reactions import reaction_types + +if TYPE_CHECKING: + from autode.species.species import Species + from autode.units import Unit + + +class Reaction: + def __init__( + self, + *args: Union[str, "Species"], + name: str = "reaction", + solvent_name: Optional[str] = None, + smiles: Optional[str] = None, + temp: Temperature = Temperature(298.15, units="K"), + ): + r""" + Elementary chemical reaction formed from reactants and products. + Number of atoms, charge and solvent must match on either side of + the reaction. For example:: + + H H H + / \ / + H + H -- C -- H ---> H--H + C + \ | + H H + + + Arguments: + args (autode.species.Species | str): Reactant and Product objects + or a SMILES string of the whole reaction. + + name (str): Name of this reaction. + + solvent_name (str | None): Name of the solvent, if None then + in the gas phase (unless reactants and + products are in a solvent). + + smiles (str | None): SMILES string of the reaction e.g. + "C=CC=C.C=C>>C1=CCCCC1" for the [4+2] + cyclization between ethene and butadiene. + + temp (float | autode.values.Temperature): Temperature in Kelvin. + """ + logger.info(f"Generating a Reaction for {name}") + + self.name = name + + self.reacs: List["Species"] = [] + self.prods: List["Species"] = [] + + self._reactant_complex: Optional[ReactantComplex] = None + self._product_complex: Optional[ProductComplex] = None + + self.tss = TransitionStates() + + # If there is only one string argument assume it's a SMILES + if len(args) == 1 and type(args[0]) is str: + smiles = args[0] + + if smiles is not None: + self._init_from_smiles(smiles) + else: + self._init_from_molecules(molecules=args) + + self.type = reaction_types.classify(self.reacs, self.prods) + self.solvent = get_solvent(solvent_name, kind="implicit") + self.temp = temp + + self._check_solvent() + self._check_balance() + self._check_names() + + def __str__(self): + """Return a very short 6 character hash of the reaction, not guaranteed + to be unique""" + + name = ( + f'{self.name}_{"+".join([r.name for r in self.reacs])}--' + f'{"+".join([p.name for p in self.prods])}' + ) + + if hasattr(self, "solvent") and self.solvent is not None: + name += f"_{self.solvent.name}" + + hasher = hashlib.sha1(name.encode()).digest() + return base64.urlsafe_b64encode(hasher).decode()[:6] + + @requires_hl_level_methods + def calculate_reaction_profile( + self, + units: Union["Unit", str] = "kcal mol-1", + single_point_refinement: bool = True, + with_complexes: bool = False, + free_energy: bool = False, + enthalpy: bool = False, + ) -> None: + """ + Calculate and plot a reaction profile for this elemtary reaction. Will + search conformers, find the lowest energy TS and plot a profile. + Calculations are performed in a new directory (self.name/) + + ----------------------------------------------------------------------- + Keyword Arguments: + units (autode.units.Unit | str): + + single_point_refinement (bool): Calculate single point at a + higher level of theory + + with_complexes (bool): Calculate the lowest energy conformers + of the reactant and product complexes + + free_energy (bool): Calculate the free energy profile (G) + + enthalpy (bool): Calculate the enthalpic profile (H) + """ + logger.info("Calculating reaction profile") + + if not Config.allow_association_complex_G and ( + with_complexes and (free_energy or enthalpy) + ): + raise NotImplementedError( + "Significant likelihood of very low " + "frequency harmonic modes – G and H. Set" + " Config.allow_association_complex_G to " + "override this" + ) + + @work_in(self.name) + def calculate(reaction): + reaction.find_lowest_energy_conformers() + reaction.optimise_reacs_prods() + reaction.locate_transition_state() + reaction.find_lowest_energy_ts_conformer() + if with_complexes: + reaction.calculate_complexes() + if free_energy or enthalpy: + reaction.calculate_thermochemical_cont() + if single_point_refinement: + reaction.calculate_single_points() + reaction.print_output() + return None + + calculate(self) + + if not with_complexes: + plot_reaction_profile( + [self], + units=units, + name=self.name, + free_energy=free_energy, + enthalpy=enthalpy, + ) + + if with_complexes: + self._plot_reaction_profile_with_complexes( + units=units, free_energy=free_energy, enthalpy=enthalpy + ) + return None + + def _check_balance(self) -> None: + """Check that the number of atoms and charge balances between reactants + and products. If they don't then raise excpetions + """ + + def total(molecules, attr): + return sum([getattr(m, attr) for m in molecules]) + + if total(self.reacs, "n_atoms") != total(self.prods, "n_atoms"): + raise UnbalancedReaction("Number of atoms doesn't balance") + + if total(self.reacs, "charge") != total(self.prods, "charge"): + raise UnbalancedReaction("Charge doesn't balance") + + # Ensure the number of unpaired electrons is equal on the left and + # right-hand sides of the reaction, for now + if total(self.reacs, "mult") - len(self.reacs) != total( + self.prods, "mult" + ) - len(self.prods): + raise NotImplementedError( + "Found a change in spin state – not " "implemented yet!" + ) + + self.charge = total(self.reacs, "charge") + return None + + def _check_solvent(self) -> None: + """ + Check that all the solvents are the same for reactants and products. + If self.solvent is set then override the reactants and products + """ + molecules = self.reacs + self.prods + if len(molecules) == 0: + return # No molecules thus no solvent needs to be checked + + first_solvent = self.reacs[0].solvent + + if self.solvent is None: + if all([mol.solvent is None for mol in molecules]): + logger.info("Reaction is in the gas phase") + return + + elif all([mol.solvent is not None for mol in molecules]): + if not all( + [mol.solvent == first_solvent for mol in molecules] + ): + raise SolventsDontMatch( + "Solvents in reactants and " "products do not match" + ) + else: + logger.info(f"Setting the solvent to {first_solvent}") + self.solvent = first_solvent + + else: + raise SolventsDontMatch( + "Some species solvated and some not. " + "Ill-determined solvation." + ) + + if self.solvent is not None: + logger.info( + f"Setting solvent to {self.solvent.name} for all " + f"molecules in the reaction" + ) + for mol in molecules: + mol.solvent = self.solvent + + assert self.solvent is not None, "Solvent cannot be undefined here" + logger.info( + f"Set the solvent of all species in the reaction to " + f"{self.solvent.name}" + ) + return None + + def _check_names(self) -> None: + """ + Ensure there is no clashing names of reactants and products, which will + cause problems when conformers are generated and output is printed + """ + all_names = [mol.name for mol in self.reacs + self.prods] + + if len(set(all_names)) == len(all_names): # Everything is unique + return + + logger.warning( + "Names in reactants and products are not unique. " + "Adding prefixes" + ) + + for i, reac in enumerate(self.reacs): + reac.name = f"r{i}_{reac.name}" + + for i, prod in enumerate(self.prods): + prod.name = f"p{i}_{prod.name}" + + return None + + def _init_from_smiles(self, reaction_smiles) -> None: + """ + Initialise from a SMILES string of the whole reaction e.g.:: + + CC(C)=O.[C-]#N>>CC([O-])(C#N)C + + for the addition of cyanide to acetone. + + ----------------------------------------------------------------------- + Arguments: + reaction_smiles (str): + """ + try: + reacs_smiles, prods_smiles = reaction_smiles.split(">>") + except ValueError: + raise UnbalancedReaction("Could not decompose to reacs & prods") + + # Add all the reactants and products with interpretable names + for i, reac_smiles in enumerate(reacs_smiles.split(".")): + reac = Reactant(smiles=reac_smiles) + reac.name = f"r{i}_{reac.formula}" + self.reacs.append(reac) + + for i, prod_smiles in enumerate(prods_smiles.split(".")): + prod = Product(smiles=prod_smiles) + prod.name = f"p{i}_{prod.formula}" + self.prods.append(prod) + + return None + + def _init_from_molecules(self, molecules) -> None: + """Set the reactants and products from a set of molecules""" + + self.reacs = [ + mol + for mol in molecules + if isinstance(mol, Reactant) or isinstance(mol, ReactantComplex) + ] + + self.prods = [ + mol + for mol in molecules + if isinstance(mol, Product) or isinstance(mol, ProductComplex) + ] + + return None + + def _components(self) -> Generator: + """Components of this reaction""" + + for mol in ( + self.reacs + + self.prods + + [self.ts, self._reactant_complex, self._product_complex] + ): + yield mol + + def _reasonable_components_with_energy(self) -> Generator: + """Generator for components of a reaction that have sensible geometries + and also energies""" + + for mol in self._components(): + if mol is None: + continue + + if mol.energy is None: + logger.warning(f"{mol.name} energy was None") + continue + + if not mol.has_reasonable_coordinates: + continue + + yield mol + + def _estimated_barrierless_delta(self, e_type: str) -> Optional[Energy]: + """ + Assume an effective free energy barrier = 4.35 kcal mol-1 calcd. + from k = 4x10^9 at 298 K (doi: 10.1021/cr050205w). Must have a ∆G_r + + ----------------------------------------------------------------------- + Arguments: + e_type (str): Type of energy to calculate: {'energy', 'enthalpy', + 'free_energy'} + Returns: + (autode.values.Energy | None): + """ + delta = self.delta(e_type) + + if delta is None: + logger.error( + f"Could not estimate barrierless {e_type}," + f" an energy was None" + ) + return None + + # Minimum barrier is the 0 for an exothermic reaction but the reaction + # energy for an endothermic reaction + value = max(Energy(0.0), delta) + + if self.type != reaction_types.Rearrangement: + logger.warning( + "Have a barrierless bimolecular reaction. Assuming" + "a diffusion limited with a rate of 4 x 10^9 s^-1" + ) + + value += Energy(0.00694, units="Ha") + + if e_type == "free_energy": + return FreeEnergy(value, estimated=True) + elif e_type == "enthalpy": + return Enthalpy(value, estimated=True) + else: + return PotentialEnergy(value, estimated=True) + + def delta(self, delta_type: str) -> Optional[Energy]: + """ + Energy difference for either reactants->TS or reactants -> products. + Allows for equivelances "E‡" == "E ddagger" == "E double dagger" all + return the potential energy barrier ∆E^‡. Can return None if energies + of the reactants/products are None but will estimate for a TS (provided + reactants and product energies are present). Example: + + .. code-block:: Python + + >>> import autode as ade + >>> rxn = ade.Reaction(ade.Reactant(), ade.Product()) + >>> rxn.delta('E') is None + True + + For reactants and products with energies: + + .. code-block:: Python + + >>> A = ade.Reactant() + >>> A.energy = 1 + >>> B = ade.Product() + >>> B.energy = 2 + >>> + >>> rxn = ade.Reaction(A, B) + >>> rxn.delta('E') + Energy(1.0 Ha) + + Arguments: + delta_type (str): Type of difference to calculate. Possibles: + {E, H, G, E‡, H‡, G‡} + + Returns: + (autode.values.Energy | None): Difference if all energies are + defined or None otherwise + """ + + def delta_type_matches(*args): + return any( + s + in delta_type.lower() + .replace("ddagger", "") + .replace("double dagger", "") + for s in args + ) + + def is_ts_delta(): + ts_synonyms = ["ddagger", "‡", "double dagger"] + return any(s in delta_type.lower() for s in ts_synonyms) + + # Determine the species on the left and right-hand sides of the equation + lhs: List[Species] = self.reacs + rhs: List[Optional[Species]] = [] + rhs += [self.ts] if is_ts_delta() else self.prods # type: ignore + + # and the type of energy to calculate + if delta_type_matches("h", "enthalpy"): + e_type = "enthalpy" + elif delta_type_matches("e", "energy") and not delta_type_matches( + "free" + ): + e_type = "energy" + elif delta_type_matches("g", "free energy", "free_energy"): + e_type = "free_energy" + else: + raise ValueError( + "Could not determine the type of energy change " + f"to calculate from: {delta_type}" + ) + + # If there is no TS estimate the effective barrier from diffusion limit + if is_ts_delta() and self.is_barrierless: + return self._estimated_barrierless_delta(e_type) + + for molecule in rhs: + assert molecule is not None, "Must have products to calc ∆E" + + # If the electronic structure has failed to calculate the energy then + # the difference between the left and right cannot be calculated + if any(getattr(mol, e_type) is None for mol in lhs + rhs): + logger.warning( + f"Could not calculate ∆{delta_type}, an energy was " f"None" + ) + return None + + return sum(getattr(mol, e_type).to("Ha") for mol in rhs) - sum( + getattr(mol, e_type).to("Ha") for mol in lhs + ) + + @property + def is_barrierless(self) -> bool: + """ + Is this reaction barrierless? i.e. without a barrier either because + there is no enthalpic barrier to the reaction, or because a TS cannot + be located. + + ----------------------------------------------------------------------- + Returns: + (bool): If this reaction has a barrier + """ + return self.ts is None + + @property + def reactant(self) -> ReactantComplex: + """ + Reactant complex comprising all the reactants in this reaction + + ----------------------------------------------------------------------- + Returns: + (autode.species.ReactantComplex): Reactant complex + """ + if self._reactant_complex is not None: + return self._reactant_complex + + return ReactantComplex( + *self.reacs, name=f"{self}_reactant", do_init_translation=True + ) + + @reactant.setter + def reactant(self, value: ReactantComplex): + """ + Set the reactant of this reaction. If unset then will use a generated + complex of all reactants + + ----------------------------------------------------------------------- + Arguments: + value (autode.species.ReactantComplex): + """ + if not isinstance(value, ReactantComplex): + raise ValueError( + f"Could not set the reactant of {self.name} " + f"using {type(value)}. Must be a ReactantComplex" + ) + + self._reactant_complex = value + + @property + def product(self) -> ProductComplex: + """ + Product complex comprising all the products in this reaction + + ----------------------------------------------------------------------- + Returns: + (autode.species.ProductComplex): Product complex + """ + if self._product_complex is not None: + return self._product_complex + + return ProductComplex( + *self.prods, name=f"{self}_product", do_init_translation=True + ) + + @product.setter + def product(self, value: ProductComplex): + """ + Set the product of this reaction. If unset then will use a generated + complex of all products + + ----------------------------------------------------------------------- + Arguments: + value (autode.species.ProductComplex): + """ + if not isinstance(value, ProductComplex): + raise ValueError( + f"Could not set the product of {self.name} " + f"using {type(value)}. Must be a ProductComplex" + ) + + self._product_complex = value + + @property + def ts(self) -> Optional[TransitionState]: + """ + _The_ transition state for this reaction. If there are multiple then + return the lowest energy but if there are no transtion states then + return None + + ----------------------------------------------------------------------- + Returns: + (autode.transition_states.TransitionState | None): + """ + return self.tss.lowest_energy + + @ts.setter + def ts(self, value: Optional[TransitionState]): + """ + Set the TS of this reaction, will override any other transition states + located. + + ----------------------------------------------------------------------- + Arguments: + value (autode.transition_states.TransitionState | None): + """ + self.tss.clear() + + if value is None: + return + + if not isinstance(value, TransitionState): + raise ValueError(f"TS of {self.name} must be a TransitionState") + + self.tss.append(value) + + def switch_reactants_products(self) -> None: + """Addition reactions are hard to find the TSs for, so swap reactants + and products and classify as dissociation. Likewise for reactions wher + the change in the number of bonds is negative + """ + logger.info("Swapping reactants and products") + + self.prods, self.reacs = self.reacs, self.prods + + if ( + self._reactant_complex is not None + and self._product_complex is not None + ): + product, reactant = ( + self._reactant_complex.to_product_complex(), + self._product_complex.to_reactant_complex(), + ) + self._product_complex, self._reactant_complex = product, reactant + return None + + @checkpoint_rxn_profile_step("reactant_product_conformers") + def find_lowest_energy_conformers(self) -> None: + """Try and locate the lowest energy conformation using simulated + annealing, then optimise them with xtb, then optimise the unique + (defined by an energy cut-off) conformers with an electronic structure + method""" + + h_method = get_hmethod() if Config.hmethod_conformers else None + for mol in self.reacs + self.prods: + # .find_lowest_energy_conformer works in conformers/ + mol.find_lowest_energy_conformer(hmethod=h_method) + + return None + + @checkpoint_rxn_profile_step("reactants_and_products") + @work_in("reactants_and_products") + def optimise_reacs_prods(self) -> None: + """Perform a geometry optimisation on all the reactants and products + using the method""" + h_method = get_hmethod() + logger.info(f"Optimising reactants and products with {h_method.name}") + + for mol in self.reacs + self.prods: + mol.optimise(h_method) + + return None + + @checkpoint_rxn_profile_step("complexes") + @work_in("complexes") + def calculate_complexes(self) -> None: + """Find the lowest energy conformers of reactant and product complexes + using optimisation and single points""" + h_method = get_hmethod() + conf_hmethod = h_method if Config.hmethod_conformers else None + + self._reactant_complex = ReactantComplex( + *self.reacs, name=f"{self}_reactant", do_init_translation=True + ) + + self._product_complex = ProductComplex( + *self.prods, name=f"{self}_product", do_init_translation=True + ) + + for species in [self._reactant_complex, self._product_complex]: + species.find_lowest_energy_conformer(hmethod=conf_hmethod) + species.optimise(method=h_method) + + return None + + @requires_hl_level_methods + @checkpoint_rxn_profile_step("transition_states") + @work_in("transition_states") + def locate_transition_state(self) -> None: + assert self.type is not None, "Must have a reaction type" + assert all( + molecule.graph is not None for molecule in self.reacs + self.prods + ), "Must have molecular graphs set for reactants and products" + + # If there are more bonds in the product e.g. an addition reaction then + # switch as the TS is then easier to find + if sum(p.graph.number_of_edges() for p in self.prods) > sum( # type: ignore + r.graph.number_of_edges() for r in self.reacs # type: ignore + ): + self.switch_reactants_products() + self.tss = find_tss(self) + self.switch_reactants_products() + else: + self.tss = find_tss(self) + + return None + + @checkpoint_rxn_profile_step("transition_state_conformers") + @work_in("transition_states") + def find_lowest_energy_ts_conformer(self) -> None: + """Find the lowest energy conformer of the transition state""" + if self.ts is None: + logger.error("No transition state to evaluate the conformer of") + return None + + else: + return self.ts.find_lowest_energy_ts_conformer() + + @checkpoint_rxn_profile_step("single_points") + @work_in("single_points") + def calculate_single_points(self) -> None: + """Perform a single point energy evaluations on all the reactants and + products using the hmethod""" + h_method = get_hmethod() + logger.info(f"Calculating single points with {h_method.name}") + + for mol in self._reasonable_components_with_energy(): + mol.single_point(h_method) + + return None + + @work_in("output") + def print_output(self) -> None: + """Print the final optimised structures along with the methods used""" + from autode.log.methods import methods + + # Print the computational methods used in this autode initialisation + with open("methods.txt", "w") as out_file: + print(methods, file=out_file) + + csv_file = open("energies.csv", "w") + method = get_hmethod() + assert ( + method.keywords.sp and method.keywords.opt + ), "High level methods must have sp and opt keywords" + print( + f"Energies generated by autodE on: {date.today()}. Single point " + f"energies at {method.keywords.sp.bstring} and optimisations at " + f"{method.keywords.opt.bstring}", + "Species, E_opt, G_cont, H_cont, E_sp", + sep="\n", + file=csv_file, + ) + + def print_energies_to_csv(_mol): + print( + f"{_mol.name}", + f"{_mol.energies.first_potential}", + f"{_mol.g_cont}", + f"{_mol.h_cont}", + f"{_mol.energies.last_potential}", + sep=",", + file=csv_file, + ) + + # Print xyz files of all the reactants and products + for mol in self.reacs + self.prods: + mol.print_xyz_file() + print_energies_to_csv(mol) + + # and the reactant and product complexes if they're present + for mol in [self._reactant_complex, self._product_complex]: # type: ignore + if mol is not None and mol.energy is not None: + mol.print_xyz_file() + print_energies_to_csv(mol) + + # If it exists print the xyz file of the transition state + if self.ts is not None: + ts_title_str = "" + imags = self.ts.imaginary_frequencies + assert imags is not None, "A TS must have an imaginary frequency" + + if self.ts.has_imaginary_frequencies and len(imags) > 0: + ts_title_str += f". Imaginary frequency = {imags[0]:.1f} cm-1" + + if self.ts.has_imaginary_frequencies and len(imags) > 1: + ts_title_str += ( + f". Additional imaginary frequencies: " f"{imags[1:]} cm-1" + ) + + print_energies_to_csv(self.ts) + self.ts.print_xyz_file(additional_title_line=ts_title_str) + self.ts.print_imag_vector(name="TS_imag_mode") + + return None + + @checkpoint_rxn_profile_step("thermal") + @work_in("thermal") + def calculate_thermochemical_cont( + self, free_energy: bool = True, enthalpy: bool = True + ) -> None: + """ + Calculate thermochemical contributions to the energies + + ----------------------------------------------------------------------- + Arguments + free_energy (bool): + + enthalpy (bool): + """ + logger.info("Calculating thermochemical contributions") + + if not (free_energy or enthalpy): + logger.info("Nothing to be done – neither G or H requested") + return None + + # Calculate G and H contributions for all components + for mol in self._reasonable_components_with_energy(): + mol.calc_thermo(temp=self.temp) + + return None + + def _plot_reaction_profile_with_complexes( + self, units: Union["Unit", str], free_energy: bool, enthalpy: bool + ) -> None: + """Plot a reaction profile with the association complexes of R, P""" + rxns = [] + + if any(mol.energy is None for mol in (self.reactant, self.product)): + raise ValueError( + "Could not plot a reaction profile with " + "association complexes without energies for" + "reaction.reactant_complex or product_complex" + ) + + # If the reactant complex contains more than one molecule then + # make a reaction that is separated reactants -> reactant complex + if len(self.reacs) > 1: + rxns.append( + Reaction( + *self.reacs, + self.reactant.to_product_complex(), + name="reactant_complex", + ) + ) + + # The elementary reaction is then + # reactant complex -> product complex + reaction = Reaction(self.reactant, self.product) + reaction.ts = self.ts + rxns.append(reaction) + + # As with the product complex add the dissociation of the product + # complex into it's separated components + if len(self.prods) > 1: + rxns.append( + Reaction( + *self.prods, + self.product.to_reactant_complex(), + name="product_complex", + ) + ) + + plot_reaction_profile( + reactions=rxns, + units=units, + name=self.name, + free_energy=free_energy, + enthalpy=enthalpy, + ) + return None + + @property + def atomic_symbols(self) -> List[str]: + """ + Atomic symbols of all atoms in this reaction sorted alphabetically. + For example: + + .. code-block:: + + >>> from autode import Atom, Reactant, Product, Reaction + >>>rxn = Reaction(Reactant(smiles='O'), + Product(atoms=[Atom('O'), Atom('H', x=0.9)]), + Product(atoms=[Atom('H')])) + >>> rxn.atomic_symbols + ['H', 'H', 'O'] + + ----------------------------------------------------------------------- + Returns: + (list(str)): List of all atoms in this reaction, with duplicates + """ + + all_atomic_symbols = [] + for reactant in self.reacs: + all_atomic_symbols += reactant.atomic_symbols + + return list(sorted(all_atomic_symbols)) + + def has_identical_composition_as(self, reaction: "Reaction") -> bool: + """Does this reaction have the same chemical identity as another?""" + return self.atomic_symbols == reaction.atomic_symbols + + def save(self, filepath: str) -> None: + """Save the state of this reaction to a binary file that can be reloaded""" + + with open(filepath, "wb") as file: + pickle.dump(self.__dict__, file) + + def load(self, filepath: str) -> None: + """Load a reaction state from a binary file""" + + with open(filepath, "rb") as file: + for attr, value in dict(pickle.load(file)).items(): + setattr(self, attr, value) + + @classmethod + def from_checkpoint(cls, filepath: str) -> "Reaction": + """Create a reaction from a checkpoint file""" + logger.info(f"Loading a reaction object from {filepath}") + rxn = cls() + rxn.load(filepath) + return rxn diff --git a/autodE/source/autode/reactions/reaction_types.py b/autodE/source/autode/reactions/reaction_types.py new file mode 100644 index 0000000000000000000000000000000000000000..b9b18fc8c7c9f6e0118bf30ce707b1dbe898323e --- /dev/null +++ b/autodE/source/autode/reactions/reaction_types.py @@ -0,0 +1,92 @@ +from typing import Sequence, Optional, TYPE_CHECKING + +from autode.exceptions import ReactionFormationFailed +from autode.log import logger + +if TYPE_CHECKING: + from autode.species import Species + + +class ReactionType: + """Type of a reaction e.g. Addition""" + + def __init__(self, name: str): + """ + Reaction type with only a name + + ----------------------------------------------------------------------- + Arguments: + name: Name of the reaction type + """ + self.name = name + + def __eq__(self, other) -> bool: + """Equality of two types is just based on their names""" + return isinstance(other, ReactionType) and self.name == other.name + + +Addition = ReactionType(name="addition") +Dissociation = ReactionType(name="dissociation") +Substitution = ReactionType(name="substitution") +Elimination = ReactionType(name="elimination") +Rearrangement = ReactionType(name="rearrangement") + + +def classify( + reactants: Sequence["Species"], + products: Sequence["Species"], +) -> Optional[ReactionType]: + """ + Classify a reaction into a type given some reactants and products + + --------------------------------------------------------------------------- + Arguments: + reactants: + products: + + Returns: + (autode.reactions.reaction_types.ReactionType): Reaction type + """ + n_reactants, n_products = len(reactants), len(products) + + if n_reactants == n_products == 0: + return None + + if n_reactants == 0: + raise ReactionFormationFailed( + f"Reaction had 0 reactants and " + f"{n_products} products. A reaction " + f"requires at least 1 reactant!" + ) + + if n_products == 0: + raise ReactionFormationFailed( + f"Reaction had 0 products but " + f"{n_reactants} reactants. A reaction " + f"requires at least 1 product!" + ) + + if n_reactants == 2 and n_products == 1: + logger.info("Classifying reaction as addition") + return Addition + + elif n_reactants == 1 and n_products in [2, 3]: + logger.info("Classifying reaction as dissociation") + return Dissociation + + elif n_reactants == 2 and n_products == 2: + logger.info("Classifying reaction as substitution") + return Substitution + + elif n_reactants == 2 and n_products == 3: + logger.info("Classifying reaction as elimination") + return Elimination + + elif n_reactants == 1 and n_products == 1: + logger.info("Classifying reaction as rearrangement") + return Rearrangement + + else: + raise NotImplementedError( + "Unsupported reaction type: " f"{n_reactants} -> {n_products}" + ) diff --git a/autodE/source/autode/smiles/__init__.py b/autodE/source/autode/smiles/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..23ec21ea82435c8919b005e032e0342b9ffbc8f9 --- /dev/null +++ b/autodE/source/autode/smiles/__init__.py @@ -0,0 +1,4 @@ +from autode.smiles.parser import Parser +from autode.smiles.builder import Builder + +__all__ = ["Parser", "Builder"] diff --git a/autodE/source/autode/smiles/angles.py b/autodE/source/autode/smiles/angles.py new file mode 100644 index 0000000000000000000000000000000000000000..2c8db7cfa765de400c7a7878a84ca907dc5a08a7 --- /dev/null +++ b/autodE/source/autode/smiles/angles.py @@ -0,0 +1,297 @@ +import numpy as np +import networkx as nx +from autode.log import logger +from autode.atoms import AtomCollection +from autode.exceptions import FailedToSetRotationIdxs, SMILESBuildFailed + + +class SAngle: + """Angle used in 3D construction from SMILES""" + + def __init__(self, idxs, rot_idxs=None, phi0=None): + """Angle between a set of atoms. In order""" + + self.idxs = idxs + self.phi_ideal = phi0 + self.rot_idxs = rot_idxs + + def __str__(self): + return f"Angle(idxs={self.idxs})" + + def __repr__(self): + return self.__str__() + + def value(self, atoms) -> float: + """ + + ----------------------------------------------------------------------- + Arguments: + atoms (list(autode.atoms.Atom)): + + Returns: + (float): Angle in radians + """ + + idx_x, idx_y, idx_z = self.idxs + vec1 = atoms[idx_x].coord - atoms[idx_y].coord + vec2 = atoms[idx_z].coord - atoms[idx_y].coord + + return np.arccos( + np.dot(vec1, vec2) / (np.linalg.norm(vec1) * np.linalg.norm(vec2)) + ) + + def _find_rot_idxs_from_pair( + self, graph, atoms, pair, max_bond_distance=4.0 + ): + """ + Split the graph across a pair of indexes and set the atom indexes + to be rotated + + Arguments: + graph (nx.Graph): + atoms (list(autode.atoms.Atom)): + pair (list(int)): len == 2 + + Keyword Arguments: + max_bond_distance (float): Maximum distance in Å that two atoms + that appear in the graph edges (bonds) + that constitutes a bond + """ + graph.remove_edge(*pair) + + # Remove all the nodes in the graph that have not been shifted, thus + # the rotation indexes only include atoms that have been 'built' + for idx, atom in enumerate(atoms): + if hasattr(atom, "is_shifted") and not atom.is_shifted: + graph.remove_node(idx) + + # Delete edges that are too far away (i.e. unclosed rings) + for idx_i, idx_j in graph.edges: + if {idx_i, idx_j} == set(pair): + logger.error("Cannot cut across a ring") + continue + + if ( + np.linalg.norm(atoms[idx_i].coord - atoms[idx_j].coord) + > max_bond_distance + ): + logger.info( + f"Bond {idx_i}-{idx_j} was not present, may " + f"remove from graph for idx location" + ) + + graph.remove_edge(idx_i, idx_j) + + # Removing edges is only possible if the graph remains intact, + # i.e. there are no stranded atoms formed by splitting, sso + # re-add the edge if two separate graphs are formed + if not nx.is_connected(graph): + graph.add_edge(idx_i, idx_j) + + components = [ + graph.subgraph(c) for c in nx.connected_components(graph) + ] + + if len(components) != 2: + raise FailedToSetRotationIdxs( + f"Splitting over {pair} did " "not afford two fragments" + ) + + # Choose the components that will be rotated + cpnt_idx = 0 if pair[0] in components[0].nodes else 1 + + self.rot_idxs = [ + 1 if i in components[cpnt_idx].nodes else 0 + for i in range(len(atoms)) + ] + return None + + def find_rot_idxs(self, graph, atoms): + """Find the atom indexes to rotate by splitting rhe graph across + the edge that appears first in the angle, e.g.:: + + Z + / + X - Y + ^ + split across this bond + + """ + return self._find_rot_idxs_from_pair(graph, atoms, pair=self.idxs[:2]) + + def inverse_rot_idxs(self, atoms): + """ + Return the inverse of a set of rotation indexes for e.g. rotating + the atoms on the other side of the angle. Skip any atoms that + have not been moved + + ----------------------------------------------------------------------- + Returns: + (list(int)): + """ + return [ + 1 + if (hasattr(atom, "is_shifted") and atom.is_shifted) + and self.rot_idxs[i] != 1 + else 0 + for i, atom in enumerate(atoms) + ] + + @property + def phi0(self): + """A non-None ideal angle, default to 100 degrees""" + return 1.74533 if self.phi_ideal is None else self.phi_ideal + + +class SAngles(list): + @property + def axes(self): + raise NotImplementedError + + @property + def origins(self): + """Origins for the rotation, as the central atom of the trio""" + return np.array([angle.idxs[1] for angle in self], dtype="i4") + + @property + def rot_idxs(self): + """Matrix of atom indexes to rotate""" + return np.array([angle.rot_idxs for angle in self], dtype="i4") + + @property + def ideal_angles(self): + """Ideal angle vector (float | None)""" + return [angle.phi_ideal for angle in self] + + def values(self, atoms): + """Current angle vector in radians""" + return np.array([angle.value(atoms) for angle in self], dtype="f8") + + def dvalues(self, atoms): + """Difference between the current and ideal angles""" + return np.array( + [angle.phi0 - angle.value(atoms) for angle in self], dtype="f8" + ) + + +class SDihedrals(SAngles): + @property + def axes(self): + return np.array([dihedral.mid_idxs for dihedral in self], dtype="i4") + + @property + def origins(self): + origins = [] + for dihedral in self: + idx_i, idx_j = dihedral.mid_idxs + origins.append(idx_i if dihedral.rot_idxs[idx_i] == 1 else idx_j) + + return np.array(origins, dtype="i4") + + +class SDihedral(SAngle): + r""" + A dihedral defined by 4 atom indexes used in building a 3D strucutre + from a SMILES string e.g.:: + + X W + | / + Y---- Z + + """ + + def __init__(self, idxs, rot_idxs=None, phi0=None, mid_dist=2.0): + r""" + A dihedral constructed from atom indexes and possibly indexes that + should be rotated, if this dihedral is altered:: + + W + \ + X --- Y + \ + Z + + ----------------------------------------------------------------------- + Arguments: + idxs (list(int)): 4 atom indexes defining the dihedral + + Keyword Arguments: + rot_idxs (list(int) | None): Indexes to rotate, 1 if the atoms + should be rotated else 0 + + phi0 (float | None): Ideal angle for this dihedral (radians) + + mid_dist (float): Optimum distance between X-Y + """ + super().__init__(idxs=idxs, rot_idxs=rot_idxs, phi0=phi0) + + # Atom indexes of the central two atoms (X, Y) + _, idx_x, idx_y, _ = idxs + + self.mid_idxs = (idx_x, idx_y) + self.mid_dist = mid_dist + + def __str__(self): + return f"Dihedral(idxs={self.idxs}, φ0={round(self.phi0, 2)})" + + @property + def end_idxs(self): + """Atoms defining the end of the dihedral""" + return self.idxs[0], self.idxs[-1] + + @property + def phi0(self): + """A non-None ideal angle for this dihedral""" + return 0.0 if self.phi_ideal is None else self.phi_ideal + + def needs_forcing(self, atoms): + """Does this dihedral angle need to be forced? i.e. has defined + stereochemistry that is not respected""" + + return ( + atoms[self.mid_idxs[0]].has_stereochem + and abs(self.dphi(atoms)) > np.pi / 3 + ) + + def dphi(self, atoms): + """∆φ = φ_curr - φ_ideal""" + return self.value(atoms=atoms) - self.phi0 + + def value(self, atoms): + """ + Calculate the value of a dihedral defined by some atoms with non-zero + positions + + ----------------------------------------------------------------------- + Arguments: + atoms (list(autode.atoms.Atom)): + + Returns: + (float): The dihedral angle in radians + + Raises: + (SMILESBuildFailed): + """ + atoms_ = AtomCollection(atoms) + try: + return float(atoms_.dihedral(*self.idxs)) + + except ValueError: + raise SMILESBuildFailed + + def find_rot_idxs(self, graph, atoms): + """ + Find the atom indexes that should be rotated for this dihedral + + ----------------------------------------------------------------------- + Arguments: + graph (nx.Graph): + + atoms (list(autode.atoms.Atom)): + """ + return self._find_rot_idxs_from_pair( + graph, + atoms, + pair=self.mid_idxs, + max_bond_distance=1.5 * self.mid_dist, + ) diff --git a/autodE/source/autode/smiles/atom_types.py b/autodE/source/autode/smiles/atom_types.py new file mode 100644 index 0000000000000000000000000000000000000000..b6dd80df379d1bd7ae095988491f1c0bc3d1f335 --- /dev/null +++ b/autodE/source/autode/smiles/atom_types.py @@ -0,0 +1,400 @@ +import numpy as np +from autode.log import logger +from scipy.spatial import distance_matrix +from autode.geom import get_rot_mat_kabsch, get_rot_mat_euler_from_terms + + +class AtomType: + def __init__(self, site_coords, is_chiral=False): + """Base atom type class + + ----------------------------------------------------------------------- + Arguments: + site_coords (list(np.ndarray)): Shape = (n, 3) should contain a + list of unit vectors pointing in directions where other + atoms can be added + + Keyword Arguments: + is_chiral (bool): Is this atom type chiral e.g. a tetrahedral atom + with four different substituents + """ + self.template_site_coords = np.copy(site_coords) + self._site_coords = site_coords + + self.is_chiral = is_chiral + self.rotate_randomly() + + @property + def n_empty_sites(self): + """Number of empty sites on this template""" + return len(self._site_coords) + + def empty_site(self): + """Iterator for the coordinate of the next free site""" + return self._site_coords.pop(0) + + def empty_site_mr(self, point, other_coords): + """Return the site on this atom that is furthest from all other + coordinates using a simple 1/r potential where r is the distance from + the site to the other coordinates + + ----------------------------------------------------------------------- + Arguments: + point (np.ndarray): Coordinate of this atom, shape = (3,) + + other_coords (np.ndarray): Other coordinates, shape = (N, 3) + + Returns: + (np.ndarray): Coordinate of the site centered at the origin + """ + dists = np.array( + [ + np.linalg.norm(other_coords - (site + point), axis=1) + for site in self._site_coords + ] + ) + + repulsion = np.sum(np.power(dists, -1), axis=1) + return self._site_coords.pop(np.argmin(repulsion)) + + def reset_onto(self, points, coord): + """ + Reset the site coordinates given a set of points. Ignore any points + located exactly at the origin and, once fitted, remove the sites + that are coincident with the points + + ----------------------------------------------------------------------- + Arguments: + points (iterable(np.ndarray)): List (or iterable) of points that + that the sites need to be reset onto + + coord (np.ndarray): Coordinate of this atom + """ + origin = np.zeros(3) + points = np.array( + [ + (point - coord) / np.linalg.norm(point - coord) + for point in points + if not np.allclose(point, origin) + ] + ) + + # Take a copy of the template coordinates to rotate and delete + site_coords = np.copy(self.template_site_coords) + + if len(site_coords) == len(points) or len(points) == 0: + logger.info("No reset needed - sites were all occupied") + return + + logger.info( + f"Rotating {len(site_coords)} sites onto" f" {len(points)} points" + ) + + # Rotate all the sites such that n sites are optimally orientated onto + # the (fixed) points + rot_mat = get_rot_mat_kabsch( + p_matrix=site_coords[: len(points)], q_matrix=points + ) + + site_coords = np.dot(rot_mat, site_coords.T).T + + # For each point (row) calculate the minimum distance to a site on + # this atom + min_dists = np.min(distance_matrix(site_coords, points), axis=1) + + # Re-populate the empty sites, which are the sites that are not the + # closest to the points + self._site_coords = [ + coord + for i, coord in enumerate(site_coords) + if i not in np.argsort(min_dists)[: len(points)] + ] + return None + + def rotate_empty_onto(self, point, coord): + """Rotate the site coordinates such that an empty site is coincident + with the vector from a coordinate to a point, and remove the site + from the list of available sites""" + return self.rotate_onto(point, coord, site=self.empty_site()) + + def rotate_randomly(self): + """Rotate the sites randomly to prevent zero cross products""" + point = np.copy(self._site_coords[0]) + point += np.random.uniform(0.01, 0.02, size=3) + + self.rotate_onto( + point=point, coord=np.zeros(3), site=self._site_coords[0] + ) + return + + def rotate_onto(self, point, coord, site): + """ + Rotate this atom type so a site is coincident with a point if this + atom is at a coord i.e.:: + + site + / + / --> + point--------coord point--site--coord + + + ----------------------------------------------------------------------- + Arguments: + point (np.ndarray): shape = (3,) + + coord (np.ndarray): shape = (3,) + + site (np.ndarray): shapte = (3,) + """ + vector = point - coord + + normal = np.cross(site, vector) + normal /= np.linalg.norm(normal) + + # Sites are normal vectors, no no need for mod + arg = np.dot(site, vector) / np.linalg.norm(vector) + + # cos(-θ/2) = √(arg + 1) / √2 + a = np.sqrt(1.0 + arg) / np.sqrt(2) + + # sin(-θ/2) = √(1-arg) / √2 + b, c, d = -normal * (np.sqrt(1.0 - arg) / np.sqrt(2)) + + # 3D rotation matrix from the Euler–Rodrigues formula + rot_matrix = get_rot_mat_euler_from_terms(a=a, b=b, c=c, d=d) + + # Rotate all the sites (no need to translate as they're already + # positioned around the origin) + self._site_coords = [ + np.matmul(rot_matrix, site) for site in self._site_coords + ] + return None + + +class TerminalAtom(AtomType): + def __init__(self): + r""" + Terminal atom with a site pointing along the x-axis:: + + Atom--- + """ + site_coords = [np.array([1.0, 0.0, 0.0])] + + super().__init__(site_coords) + + +class LinearAtom(AtomType): + def __init__(self): + r""" + Linear atom with sites pointing along the x-axis:: + + ---Atom--- + + WARNING: Completely linear cause failures for internal coordinate + optimisation algorithms and have undefined cross products. + Site coordinates are therefore not precisely linear + """ + site_coords = [np.array([1.0, 0.0, 0.0]), np.array([-1.0, 0.001, 0.0])] + + super().__init__(site_coords) + + +class BentAtom(AtomType): + def __init__(self): + r""" + Bent atom with sites generated by optimisation of H2O:: + + Atom + / \ + """ + site_coords = [ + np.array([-0.78226654, -0.62294387, 0.0]), + np.array([0.78322832, -0.62173419, 0.0]), + ] + + super().__init__(site_coords) + + +class TrigonalPyramidalAtom(AtomType): + def __init__(self): + r""" + Trigonal pyramidal atom e.g. P in PH3, obtained from optimisation of + ammonia (then normalising NH distances to 1 Å):: + + Atom + / | \ + """ + site_coords = [ + np.array([0.90023489, -0.14794295, -0.40949973]), + np.array([-0.58738609, -0.70512041, -0.39721881]), + np.array([-0.32432922, 0.85865859, -0.39688283]), + ] + + super().__init__(site_coords) + + +class TrigonalAtom(AtomType): + def __init__(self): + r""" + Trigonal atom e.g. [CR3]+ , obtained from optimisation of BH3 + (then normalising NH distances to 1 Å):: + + / + --- Atom + \ + """ + site_coords = [ + np.array([-0.506363095, -0.862320319, 0.0]), + np.array([-0.495155944, 0.868804058, 0.0]), + np.array([0.999977780, -0.006666131, 0.0]), + ] + + super().__init__(site_coords) + + +class TetrahedralAtom(AtomType): + def __init__(self): + r""" + Tetrahedral atom with sites generated by optimisation of methane + (then normalising CH distances to 1 Å):: + + | / + Atom + / \ + + """ + site_coords = [ + np.array([-0.404709, 0.86798519, -0.28777090]), + np.array([-0.580775, -0.75435372, -0.30602419]), + np.array([0.0763827, -0.01927872, 0.99689218]), + np.array([0.9089159, -0.09390161, -0.40626889]), + ] + + super().__init__(site_coords) + + +class TetrahedralNAtom(TetrahedralAtom): + """A 'normal' order chiral tetrahedral atom""" + + def __init__(self): + super().__init__() + self.is_chiral = True + + +class TetrahedralIAtom(TetrahedralAtom): + """An 'inverted' order chiral tetrahedral atom""" + + def empty_site(self): + """Swap the first two yielded site coordinates, effectively swapping + the chirality this atom's neighbours are added""" + + if len(self._site_coords) == 3: + return self._site_coords.pop(1) + + else: + return super().empty_site() + + def __init__(self): + super().__init__() + self.is_chiral = True + + +class SquarePlanarAtom(AtomType): + def __init__(self): + r""" + Square planar atom with sites generated by optimisation of XeF4:: + + | + -- Atom -- + | + """ + site_coords = [ + np.array([-0.99779169, 0.06642094, 0.0]), + np.array([0.06641523, 0.99779207, 0.0]), + np.array([0.99779219, -0.06641349, 0.0]), + np.array([-0.06642889, -0.99779116, 0.0]), + ] + + super().__init__(site_coords) + + +class TrigonalBipyramidalAtom(AtomType): + def __init__(self): + r""" + Trigonal bipyramidal atom with sites generated by optimisation of + [Cn(Cl)5]-:: + + | + | + --- Atom -- + / | + | + """ + site_coords = [ + np.array([-0.96060076, 0.0333159, -0.27592795]), + np.array([0.27025683, -0.50748281, 0.8181824]), + np.array([0.68796654, 0.4787655, -0.54542243]), + np.array([-0.13392744, 0.82131359, 0.55453352]), + np.array([0.13543747, -0.82200953, -0.55313383]), + ] + + super().__init__(site_coords) + + +class OctahedralAtom(AtomType): + def __init__(self): + r""" + Octahedral atom with sites generated by optimisation of [Co(Cl)6]3-:: + + | + | / + --- Atom -- + / | + | + """ + site_coords = [ + np.array([0.06037748, 0.86107926, 0.50487332]), + np.array([-0.96772781, -0.0717284, 0.24157384]), + np.array([-0.06059905, -0.86084488, -0.50524632]), + np.array([-0.2330902, 0.50187662, -0.83293986]), + np.array([0.23246809, -0.50140995, 0.83339465]), + np.array([0.96764951, 0.07148532, -0.24195925]), + ] + + super().__init__(site_coords) + + +class PentagonalBipyramidalAtom(AtomType): + def __init__(self): + """Approximate trigonal pentagonal geometry by optimisation of IF7""" + + site_coords = [ + np.array([-0.82513358, 0.19948399, 0.52854584]), + np.array([0.36100434, 0.82474278, 0.4352875]), + np.array([-0.2989535, -0.86729114, -0.39803628]), + np.array([0.07965322, -0.50323863, 0.86046862]), + np.array([-0.68679889, 0.35535251, -0.63405984]), + np.array([0.92361199, -0.38311825, 0.0127003]), + np.array([0.4702203, 0.36157889, -0.80507986]), + ] + + super().__init__(site_coords) + + +class SquareAntiprismAtom(AtomType): + def __init__(self): + """ + Approximate square antiprism geometry by optimisation of [XeF8]2- + """ + site_coords = [ + np.array([-0.12556124, 0.56801979, -0.81338053]), + np.array([-0.9236697, -0.18379172, -0.33623635]), + np.array([-0.13553555, -0.94631881, 0.29344647]), + np.array([0.19928886, -0.57504162, -0.79348036]), + np.array([0.54323185, -0.11585026, 0.83155148]), + np.array([-0.62763462, 0.12547974, 0.76832911]), + np.array([0.10138916, 0.94996354, 0.29544797]), + np.array([0.95080191, 0.18740602, -0.24668749]), + ] + + super().__init__(site_coords) diff --git a/autodE/source/autode/smiles/base.py b/autodE/source/autode/smiles/base.py new file mode 100644 index 0000000000000000000000000000000000000000..53035964cf1ba58232924feff195f363125b6589 --- /dev/null +++ b/autodE/source/autode/smiles/base.py @@ -0,0 +1,282 @@ +import enum +import numpy as np + +from typing import Optional, SupportsIndex +from autode.log import logger +from autode.atoms import Atom +from autode.exceptions import InvalidSmilesString + +bond_order_symbols = ["-", "=", "#", "$"] +organic_symbols = ["B", "C", "N", "O", "P", "S", "F", "Cl", "Br", "I"] +aromatic_symbols = ["b", "c", "n", "o", "s", "p"] + + +@enum.unique +class SMILESStereoChem(enum.Enum): + NONE = 0 + + TET_NORMAL = 1 + TET_INVERTED = -1 + + ALKENE_UP = 2 + ALKENE_DOWN = -2 + + +class SMILESAtom(Atom): + """Atom in a SMILES string""" + + def __init__( + self, + label: str, + stereochem: SMILESStereoChem = SMILESStereoChem.NONE, + n_hydrogens: Optional[int] = None, + charge: int = 0, + atom_class: Optional[int] = None, + ): + """ + SMILES atom initialised at the origin + + ---------------------------------------------------------------------- + Arguments: + label: Label / atomic symbol of this atom + + n_hydrogens: Number of hydrogens, None means unset and should be + determined implicitly + + stereochem: Point stereochemistry around this atom (R, S) + + charge: Formal charge on this atom + + atom_class: Class of an atom. See §3.1.7 in the SMILES spec + http://opensmiles.org/opensmiles.html + """ + super().__init__(atomic_symbol=label.capitalize()) + + # SMILES label may be distinct from the atom label, e.g. aromatic atoms + self.smiles_label = label + + self.charge = charge + self.n_hydrogens = n_hydrogens + self.stereochem = stereochem + self.atom_class = atom_class + + # ---------- Attributes used for building the 3D structure ---------- + self.type = None + self.neighbours = None + self.in_ring = False + self._is_pi = False if label not in aromatic_symbols else True + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return f"SMILESAtom({self.label}, stereo={self.stereochem})" + + @property + def is_shifted(self): + """Has this atom been shifted from the origin?""" + return False if np.allclose(self.coord, np.zeros(3)) else True + + @property + def is_aromatic(self): + """Is this atom 'aromatic'?""" + return self.smiles_label in aromatic_symbols + + @property + def has_stereochem(self): + """Does this atom have associated stereochemistry?""" + return self.stereochem is not SMILESStereoChem.NONE + + @property + def n_bonded(self): + """How many atoms are bonded to this one?""" + return 0 if self.neighbours is None else len(self.neighbours) + + def is_pi(self, valency: int = 0) -> bool: + # WARNING: does not respect the argument.. + return self._is_pi + + def invert_stereochem(self): + """Invert the stereochemistry at this centre""" + logger.info("Inverting stereochemistry") + self.stereochem = SMILESStereoChem(-self.stereochem.value) + return + + +class SMILESBond: + """Bond in a SMILES string""" + + def __init__(self, idx_i: int, idx_j: int, symbol: str): + """ + Bond between two atoms from a SMILES string, sorted from low to high + + ----------------------------------------------------------------------- + Arguments: + idx_i (int): + + idx_j (int): + + symbol (str): Bond order symbol + """ + self._list = [idx_i, idx_j] + + if symbol not in bond_order_symbols: + raise InvalidSmilesString(f"{symbol} is an unknown bond type") + + self.closes_ring = False + self.order = bond_order_symbols.index(symbol) + 1 + + self.r0 = None # Ideal bond distance (Å) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return f"SMILESBond({self._list}, order={self.order})" + + def __getitem__(self, item): + return self._list[item] + + @property + def atom_indexes(self): + """Atom indexes for the atoms in this bond""" + return {self._list[0], self._list[1]} + + def is_cis(self, atoms): + """Is this bond a cis double bond? + + ----------------------------------------------------------------------- + Arguments: + atoms (list(autode.smiles.base.SMILESAtom)): + """ + i, j = self._list + + if atoms[i].stereochem is None or atoms[j].stereochem is None: + return False + + return self.order == 2 and atoms[i].stereochem == atoms[j].stereochem + + def is_trans(self, atoms): + """Is this bond a trans double bond? + + Undefined stereochemistry defaults to trans + + ----------------------------------------------------------------------- + Arguments: + atoms (list(autode.smiles.base.SMILESAtom)): + """ + return self.order == 2 and not self.is_cis(atoms) + + def in_ring(self, rings_idxs): + """ + Is this bond a constituent of a ring + + ----------------------------------------------------------------------- + Arguments: + rings_idxs (collection(collection(int))): + + Returns: + (bool): + """ + + for ring_idxs in rings_idxs: + if set(self._list).issubset(set(ring_idxs)): + return True + + return False + + def distance(self, atoms): + """Distance of this bond (Å) given a set of atoms""" + idx_i, idx_j = self._list + return np.linalg.norm(atoms[idx_i].coord - atoms[idx_j].coord) + + @property + def symbol(self): + """SMILES symbol for this bond e.g. # for a triple bond""" + return bond_order_symbols[self.order - 1] + + @symbol.setter + def symbol(self, value): + """Allow for a symbol to be set, keeping track of only the order""" + self.order = bond_order_symbols.index(value) + 1 + + +class RingBond(SMILESBond): + """Dangling bond created when a ring is found""" + + def __repr__(self): + return f"RingSMILESBond({self._list}, order={self.order})" + + def close(self, idx, symbol): + """Close this bond using an atom index""" + self._list = list(sorted([self[0], idx])) + + # Only override implicit single bonds with double, triple etc. + if self.symbol == "-": + self.symbol = symbol + + return None + + def in_ring(self, rings_idxs): + return True + + def __init__(self, idx_i, symbol, bond_idx=None): + """Initialise the bond with a non-existent large index + + ----------------------------------------------------------------------- + Arguments: + idx_i (int): Index of one atom in this bond + + symbol (str): Symbol of this bond, in bond_order_symbols + + bond_idx (None | int): Index for this bond in the bond list + """ + super().__init__(idx_i=idx_i, idx_j=99999, symbol=symbol) + + self.closes_ring = True + self.bond_idx = bond_idx + + +class SMILESBonds(list): + def _bond_exists(self, bond): + """Does this bond already exist in this set of bonds?""" + return any(bond.atom_indexes == item.atom_indexes for item in self) + + def n_involving(self, idx): + """How many bonds does an atom (given as a index) have?""" + return len(self.involving(idx)) + + def involving(self, *args): + """Get all the bonds involving a particular atom (given as a index) + + ----------------------------------------------------------------------- + Arguments: + args (int): + + Returns: + (list(autode.smiles.SMILESBond)): + """ + idxs = set(args) + + return [bond for bond in self if idxs.issubset(set(bond.atom_indexes))] + + def first_involving(self, *args): + """First bond that includes some atom indexes""" + idxs = set(args) + return next(b for b in self if idxs.issubset(set(b.atom_indexes))) + + def append(self, bond: SMILESBond): + """Add another SMILESBond to this list""" + + if self._bond_exists(bond) or len(set(bond.atom_indexes)) != 2: + return + + return super().append(bond) + + def insert(self, index: SupportsIndex, bond: SMILESBond): + """Insert a bond into this list if it does not already exist""" + + if self._bond_exists(bond) or len(set(bond.atom_indexes)) != 2: + return + + return super().insert(index, bond) diff --git a/autodE/source/autode/smiles/builder.py b/autodE/source/autode/smiles/builder.py new file mode 100644 index 0000000000000000000000000000000000000000..28febc64d877d7bac439819a68dd4d2f156f59c9 --- /dev/null +++ b/autodE/source/autode/smiles/builder.py @@ -0,0 +1,1064 @@ +import numpy as np +import networkx as nx +from autode.smiles import atom_types +from autode.log import logger +from autode.utils import log_time +from autode.atoms import Atom, AtomCollection +from autode.mol_graphs import MolecularGraph +from autode.smiles.base import SMILESAtom, SMILESBond, SMILESStereoChem +from autode.smiles.angles import SDihedral, SDihedrals, SAngle, SAngles +from ade_dihedrals import rotate, closed_ring_coords +from ade_rb_opt import opt_rb_coords +from autode.exceptions import ( + SMILESBuildFailed, + FailedToSetRotationIdxs, + FailedToAdjustAngles, +) + + +class Builder(AtomCollection): + """ + 3D geometry builder:: + + Atoms: C, 4H H H + Bonds: 4 x C-H --> C + H H + + """ + + def __init__(self): + """ + Coordinate builder initialised from a set of atoms and bonds connecting + them. This builder should generate something *reasonable* that can + be cleaned up with a forcefield + """ + super().__init__() + + self.atoms = None # list(SMILESAtom) + self.bonds = None # SMILESBonds + self.graph = None # nx.Graph + self.rings_idxs = None # Iterator for atom indexes in all rings + + # A queue of atom indexes, the neighbours for which need to be added + self.queued_atoms = [] + + # A queue of dihedrals that need to be applied + self.queued_dihedrals = SDihedrals() + + @property + def built(self): + """Have all the atoms been shifted appropriately? + + ----------------------------------------------------------------------- + Returns: + (bool): + """ + return self.atoms is not None and len(self.queued_atoms) == 0 + + @property + def canonical_atoms(self): + """Generate canonical autodE atoms from this set + + ----------------------------------------------------------------------- + Returns: + (list(autode.atoms.Atom)): Atoms + """ + + atoms = [] + for atom in self.atoms: + x, y, z = atom.coord + atoms.append( + Atom(atom.label, x=x, y=y, z=z, atom_class=atom.atom_class) + ) + + return atoms + + @property + def canonical_atoms_at_origin(self): + """Canonical set of autodE atoms all located at the origin + + ----------------------------------------------------------------------- + Returns: + (list(autode.atoms.Atom)): Atoms all with atom.coord = [0, 0, 0] + """ + return [Atom(atom.label) for atom in self.atoms] + + @property + def built_atom_idxs(self): + """Atom indexes that have been built + + ----------------------------------------------------------------------- + Returns: + (list(int)): Atom indexes + """ + return [i for i in range(self.n_atoms) if self.atoms[i].is_shifted] + + @property + def non_bonded_idx_matrix(self): + """ + Generate a matrix of ones if atoms are non-bonded and zero if for + self pairs or they are bonded + + ----------------------------------------------------------------------- + Returns: + (np.ndarray): shape = (n_atoms, n_atoms) + """ + + idxs = np.ones(shape=(self.n_atoms, self.n_atoms), dtype="i4") + np.fill_diagonal(idxs, 0) # Exclude self-repulsion + + for bond in self.bonds: + idx_i, idx_j = bond + idxs[idx_i, idx_j] = idxs[idx_j, idx_i] = 0 + + # Do not include any atoms that have yet to be built + for i, atom in enumerate(self.atoms): + if not atom.is_shifted: + idxs[i, :] = idxs[:, i] = 0 + + return idxs + + @property + def max_ring_n(self): + """Maximum ring size in this molecule + + ----------------------------------------------------------------------- + Returns: + (int): Maximum ring size + """ + + if self.rings_idxs is None or len(self.rings_idxs) == 0: + return 0 + + return max(len(idxs) for idxs in self.rings_idxs) + + def _atom_is_d8(self, idx): + """ + Is an atom a d8 metal? Only consider a subset of the platinum group + elements + + ----------------------------------------------------------------------- + Arguments: + idx (int): + + Returns: + (bool): + """ + atom = self.atoms[idx] + + if atom.atomic_symbol not in ["Rh", "Pd", "Ir", "Pt"]: + return False + + dn = atom.group - atom.charge # Initial number of d electrons + + for bond in self.bonds.involving(idx): + # Only remove an electron if a ligand is singly bonded (X) and + # treat all double bonds as L2 ligands, rather than X2 + if bond.order % 2 == 1: + dn -= 1 + + logger.info(f"{atom}, dn = {dn}") + return dn == 8 + + def _explicit_all_hydrogens(self): + """Convert all implicit hydrogens to explicit ones""" + + h_atoms = [] + for idx, atom in enumerate(self.atoms): + if not hasattr(atom, "n_hydrogens") or atom.n_hydrogens is None: + logger.warning( + f"{atom} did not have a defined number of " + "hydrogens. Assuming 0" + ) + atom.n_hydrogens = 0 + + for _ in range(atom.n_hydrogens): + h_atoms.append(SMILESAtom("H", n_hydrogens=0)) + + # Add the bond between the current atom and the new H + h_idx = self.n_atoms + len(h_atoms) - 1 + self.bonds.append(SMILESBond(idx, h_idx, symbol="-")) + + # zero the number of implicit hydrogens bonded to this atom now + # they are explicit + atom.n_hydrogens = 0 + + self.atoms += h_atoms + return + + def _set_atom_types(self): + """ + Set the atom types for all atoms, where the atom type is determined + by the number of bonded atoms, and the 'hybridisation' as well as + the stereochemistry + """ + logger.info(f"Setting {self.n_atoms} atom types") + + self.rings_idxs = nx.minimum_cycle_basis(self.graph) + logger.info(f"Have {len(self.rings_idxs)} ring(s)") + + for i, atom in enumerate(self.atoms): + atom.coord = np.zeros(3) + atom.neighbours = list(self.graph.neighbors(i)) + atom.in_ring = len(self._ring_idxs([i], return_empty=True)) > 0 + + if not isinstance(atom, SMILESAtom): + raise SMILESBuildFailed("Builder requires SMILESAtom-s") + + if atom.n_bonded == 0: + # No type is needed for an isolated atom + continue + + elif atom.n_bonded == 1: # e.g. H2, FCH3 + atom.type = atom_types.TerminalAtom() + + elif atom.n_bonded == 2: # e.g. OH2, SR2 + if atom.group == 16: + atom.type = atom_types.BentAtom() + + elif atom.group == 15: # e.g. H2C=NH + atom.type = atom_types.TrigonalAtom() + + else: # e.g. AuR2 + atom.type = atom_types.LinearAtom() + + elif atom.n_bonded == 3: # e.g. NH3 + if atom.group == 15: + atom.type = atom_types.TrigonalPyramidalAtom() + + else: # e.g. BH3 + atom.type = atom_types.TrigonalAtom() + + elif atom.n_bonded == 4: # e.g. CH4 + if atom.atomic_symbol == "Xe": # e.g. XeF4 + atom.type = atom_types.SquarePlanarAtom() + + # Second row transition metals that are d8 should be sq planar + elif self._atom_is_d8(idx=i) and atom.period == 5: + atom.type = atom_types.SquarePlanarAtom() + + elif atom.stereochem == SMILESStereoChem.TET_NORMAL: + atom.type = atom_types.TetrahedralNAtom() + + elif atom.stereochem == SMILESStereoChem.TET_INVERTED: + atom.type = atom_types.TetrahedralIAtom() + + else: + atom.type = atom_types.TetrahedralAtom() + + elif atom.n_bonded == 5: + atom.type = atom_types.TrigonalBipyramidalAtom() + + elif atom.n_bonded == 6: + atom.type = atom_types.OctahedralAtom() + + elif atom.n_bonded == 7: + atom.type = atom_types.PentagonalBipyramidalAtom() + + elif atom.n_bonded == 8: + atom.type = atom_types.SquareAntiprismAtom() + + else: + raise NotImplementedError( + "Coordination numbers >8 are not" "(yet) supported" + ) + + return None + + def _ring_idxs(self, inc_idxs, return_empty=False): + """Indexes of atoms in the ring containing this bond + + ----------------------------------------------------------------------- + Arguments: + inc_idxs (list(int)): List of atom indexes that need to be included + in the ring + + Keyword Arguments: + return_empty (bool): + + Returns: + (list(int)): Atom indexes in this ring if they can be found + + Raises: + (autode.exceptions.SMILESBuildFailed): If there is no such ring + """ + try: + return next( + idxs + for idxs in self.rings_idxs + if all(idx in idxs for idx in inc_idxs) + ) + + except StopIteration: + if return_empty: + return [] + + raise SMILESBuildFailed(f"No ring containing {inc_idxs}") + + def _ring_path(self, ring_bond): + """ + Find the path which traverses a ring closed by a ring bond + + C2----C3 + / | --> 1, 2, 3, 4 + C1 **** C4 + ^ + ring bond + + ----------------------------------------------------------------------- + Arguments: + ring_bond (autode.smiles.SMILESBond): + + Returns: + (nx.path_generator): + + Raises: + (SMILESBuildFailed): If a suitable path is not found + """ + ring_idxs = self._ring_idxs(ring_bond) + + paths = nx.shortest_simple_paths( + self.graph, source=ring_bond[0], target=ring_bond[1] + ) + + for possible_path in paths: + # Can always have a path that traverses the ring bond (C1-C4 above) + if len(possible_path) == 2: + continue + + # For multiple fused rings there may be other paths that could be + # traversed, so only take the one that has the appropriate idxs + if all(idx in ring_idxs for idx in possible_path): + return possible_path + + raise SMILESBuildFailed("Could not find path in ring") + + def _ring_dihedrals(self, ring_bond): + """ + Given a ring bond find all the rotatable dihedrals that can be adjusted + to close it with a reasonable bond distance + + ----------------------------------------------------------------------- + Arguments: + ring_bond (autode.smiles.SMILESBond): + + Yields: + (iterator(autode.smiles.builder.Dihedral)): + + Raises: + (autode.exceptions.SMILESBuildFailed): If dihedrals cannot be + located + """ + path = self._ring_path(ring_bond=ring_bond) + + # The dihedrals are then all the 4 atom tuples in sequence + dihedral_idxs = [tuple(path[i : i + 4]) for i in range(len(path) - 3)] + + # so only add the indexes where the bond (edge) order is one + for i, dihedral_idxs in enumerate(dihedral_idxs): + dihedral = SDihedral(dihedral_idxs) + + # Optimum distance between the two middle atoms, used for + # determining if a bond exists thus a dihedral can be rotated + dihedral.mid_dist = self.bonds.first_involving( + *dihedral.mid_idxs + ).r0 + + # If both atoms either side of this one are 'pi' atoms e.g. in a + # benzene ring, then the ideal angle must be 0 to close the ring + if all(self.atoms[idx].is_pi() for idx in dihedral.mid_idxs): + dihedral.phi_ideal = 0.0 + + # Only yield single bonds, that can be rotated freely + if self.graph.get_edge_data(*dihedral.mid_idxs)["order"] == 1: + yield dihedral + + def _reset_queued_atom_sites(self, other_idxs=None): + """ + When a dihedral rotation(s) is(are) performed the rotation is not + applied to the empty sites that are present in the queued atoms, + they therefore need to be reset + + ----------------------------------------------------------------------- + Keyword Arguments: + other_idxs (list | set | None): Other indexes that need to be reset + """ + for idx_i in set( + self.queued_atoms + + list(other_idxs if other_idxs is not None else []) + ): + logger.info(f"Resetting sites on atom {idx_i}") + + atom = self.atoms[idx_i] + points = [self.atoms[idx].coord for idx in atom.neighbours] + + # Resetting an atom onto two atoms can fail to apply the stereochem + # thus only set it onto one + if atom.has_stereochem and len(points) == 2: + points = points[:1] + + atom.type.reset_onto(points, coord=atom.coord) + + return None + + @log_time(prefix="Closed ring in:", units="ms") + def _adjust_ring_dihedrals(self, ring_bond, dihedrals): + """Outsource the ring closure to an external function""" + logger.info("Adjusting ring dihedrals to close the ring") + + coords = closed_ring_coords( + py_coords=self.coordinates, + py_curr_angles=dihedrals.values(self.atoms), + py_ideal_angles=dihedrals.ideal_angles, + py_axes=dihedrals.axes, + py_rot_idxs=dihedrals.rot_idxs, + py_origins=dihedrals.origins, + py_rep_idxs=self.non_bonded_idx_matrix, + py_close_idxs=np.array(tuple(ring_bond), dtype="i4"), + py_r0=ring_bond.r0, + ) + self.coordinates = coords + return + + def _adjust_ring_angles(self, ring_bond): + """Shift angles in a ring to close e.g. in a cyclopropane the 109º + angles between carbons are much to large to generate a sensible + geometry no matter the dihedral angles, so compress the C-C-C angles + to 60º to close the ring e.g:: + + + C2---- C3 C2 + / --> / | + C1 C1 ---C3 + + ----------------------------------------------------------------------- + Arguments: + ring_bond (autode.smiles.base.RingBond): + """ + + path = self._ring_path(ring_bond=ring_bond) + ring_n = len(path) + + if ring_n >= 5: + logger.warning("Closing large rings not implemented") + raise FailedToAdjustAngles + + angles_idxs = [tuple(path[i : i + 3]) for i in range(len(path) - 2)] + logger.info(f"Adjusting {len(angles_idxs)} angles to close a ring") + + angles = SAngles() + + for angle_idxs in angles_idxs: + graph = self.graph.copy() + graph.remove_edge(ring_bond[0], ring_bond[1]) + + angle = SAngle( + idxs=angle_idxs, phi0=(np.pi - (2.0 * np.pi / ring_n)) + ) + + try: + angle.find_rot_idxs(graph=graph, atoms=self.atoms) + + except FailedToSetRotationIdxs: + logger.warning(f"Could not adjust angle {angle_idxs}") + raise FailedToAdjustAngles + + angle_alt = SAngle( + idxs=angle_idxs, + rot_idxs=angle.inverse_rot_idxs(self.atoms), + phi0=angle.phi0, + ) + + angles.append(angle) + angles.append(angle_alt) + + coords = self.coordinates + axes = [] + rot_idxs = [angle.rot_idxs for angle in angles] + + for i, angle in enumerate(angles): + idx_x, idx_y, idx_z = angle.idxs + + axis = np.cross( + coords[idx_x, :] - coords[idx_y, :], + coords[idx_z, :] - coords[idx_y, :], + ) + + # Alternate between forward and reverse rotations + if i % 2 == 0: + axis *= -1 + + # Append the axis onto the coordinates + coords = np.concatenate( + (coords, np.expand_dims(axis + coords[idx_y, :], axis=0)) + ) + + # Now the axis is coords[-1] - coods[idx_y], so + axes.append([coords.shape[0] - 1, idx_y]) + + # Append zeros to the rotated indexes corresponding to the + # number of added coordinates (axes) + rot_idxs[i] += len(angles) * [0] + + coords = rotate( + py_coords=coords, + py_angles=angles.dvalues(self.atoms) / 2, + py_axes=np.array(axes, dtype="i4"), + py_rot_idxs=np.array(rot_idxs, dtype="i4"), + py_origins=angles.origins, + ) + + self.coordinates = coords[: -len(angles), :] + return + + def _ff_distance_matrix(self, dist_consts=None): + """Generate a distance matrix for all pairs of atoms and + update any distance constraints that are required + + ----------------------------------------------------------------------- + Arguments: + dist_consts (dict | None): Keyed with atoms pairs and values of + the distances + """ + + dist_consts = dist_consts if dist_consts is not None else {} + built_idxs = self.built_atom_idxs + + r0 = np.zeros((len(built_idxs), len(built_idxs)), dtype="f8") + + for bond in self.bonds: + idx_i, idx_j = bond + + if idx_i not in built_idxs or idx_j not in built_idxs: + continue + + # Indexes are different as only a subset of atoms will + # be minimised and their coordinates set + i, j = built_idxs.index(idx_i), built_idxs.index(idx_j) + + # This pair is bonded and has an already set ideal distance + r0[i, j] = r0[j, i] = bond.r0 + + if bond.order != 2: + continue + + if bond.in_ring(self.rings_idxs) and bond.is_cis(self.atoms): + logger.info("cis double bond in ring not adding constraint") + continue + + logger.info("Double bond - adding constraint") + try: + idx_in = next( + idx + for idx in iter(self.atoms[idx_i].neighbours) + if self.atoms[idx].is_shifted + ) + idx_jn = next( + idx + for idx in iter(self.atoms[idx_j].neighbours) + if self.atoms[idx].is_shifted + ) + + pair = (idx_in, idx_jn) + + except StopIteration: + logger.warning( + "Could not fix stereochemistry, no neighbours " + "to add constraints to" + ) + continue + + # A single distance constraint will be enough?! + if all(p not in dist_consts for p in (pair, reversed(pair))): + dist_consts[pair] = self.distance(*pair) + + # Set the items in the distance matrix, given that this may be a subset + # of the full atoms, with different indexes + for (idx_i, idx_j), distance in dist_consts.items(): + i, j = built_idxs.index(idx_i), built_idxs.index(idx_j) + r0[i, j] = r0[j, i] = distance + + return r0 + + def _ff_minimise(self, distance_constraints=None): + """Minimise all built atoms using a forcefield""" + + built_idxs = self.built_atom_idxs + n_atoms = len(built_idxs) + + # Define ideal distances for pairs of atoms that are bonded + r0 = self._ff_distance_matrix(distance_constraints) + bond_matrix = np.zeros(shape=(n_atoms, n_atoms), dtype=bool) + bond_matrix[r0 != 0.0] = True + + # No repulsion between bonded atoms + c = np.ones((n_atoms, n_atoms), dtype="f8") + c -= np.asarray(bond_matrix, dtype="f8") + c *= 0.8 + + # and less repulsion between H and other atoms + h_idxs = np.array( + [ + built_idxs.index(idx) + for idx in built_idxs + if self.atoms[idx].label == "H" + ], + dtype=int, + ) + c[h_idxs, h_idxs] *= 0.01 + + # Now minimise all coordinates that are bonded + coords = self.coordinates + opt_cs = opt_rb_coords( + py_coords=coords[built_idxs], + py_bonded_matrix=bond_matrix, + py_r0_matrix=np.asarray(r0, dtype="f8"), + py_k_matrix=np.ones((n_atoms, n_atoms), dtype="f8"), + py_c_matrix=c, + py_exponent=4, + ) + + # Set the partial coordinate set + coords[built_idxs] = opt_cs + self.coordinates = coords + return None + + def _close_ring(self, ring_bond): + """ + Adjust ring dihedrals such that a ring is formed + + ----------------------------------------------------------------------- + Arguments: + ring_bond (autode.smiles.SMILESBond): + """ + logger.info(f"Closing ring on: {ring_bond} and adjusting atoms") + + dihedrals = SDihedrals() + for dihedral in self._ring_dihedrals(ring_bond): + # Generate a graph without the ring or this dihedral to locate + # the indexes that should be rotated + graph = self.graph.copy() + graph.remove_edge(*ring_bond) + + try: + dihedral.find_rot_idxs(graph=graph, atoms=self.atoms) + + except FailedToSetRotationIdxs: + logger.warning( + f"Could not rotate dihedral {dihedral} " + f"splitting across {dihedral.mid_idxs} did not " + f"afford two fragments" + ) + continue + + dihedrals.append(dihedral) + + if len(dihedrals) == 0: + logger.info("No dihedrals to adjust to close the ring") + + else: + self._adjust_ring_dihedrals(ring_bond, dihedrals=dihedrals) + + if not np.isclose( + ring_bond.distance(self.atoms), ring_bond.r0, atol=0.2 + ): + logger.info(f"A ring was poorly closed - adjusting angles") + + try: + self._adjust_ring_angles(ring_bond) + + except FailedToAdjustAngles: + logger.warning( + "Failed to close a ring, minimising on " "all atoms" + ) + self._ff_minimise() + + self._reset_queued_atom_sites(other_idxs=ring_bond) + return None + + @log_time(prefix="Performed final dihedral rotation in:", units="ms") + def _minimise_non_ring_dihedrals(self): + """ + Minimise the repulsive pairwise energy with respect to all non-ring + dihedral rotations:: + + Z + | + X -----Y + / + W + + """ + logger.info("Minimising non-bonded repulsion by dihedral rotation") + + dihedrals = SDihedrals() + + for bond in self.bonds: + if bond.order != 1: + continue + + # Check that both atoms that form this bond have > 1 neighbours, + # thus define a dihedral + idx_x, idx_y = bond + + # Find the other atoms that form the 4 atom tuple + try: + idx_w = next( + idx + for idx in self.atoms[idx_x].neighbours + if idx != idx_y and self.atoms[idx].n_bonded > 1 + ) + idx_z = next( + idx + for idx in self.atoms[idx_y].neighbours + if idx != idx_x and self.atoms[idx].n_bonded > 1 + ) + + except StopIteration: + continue # No suitable neighbours + + dihedral = SDihedral(idxs=[idx_w, idx_x, idx_y, idx_z]) + + try: + dihedral.find_rot_idxs(self.graph.copy(), atoms=self.atoms) + + except FailedToSetRotationIdxs: + continue # Bond could be in a ring etc. + + dihedrals.append(dihedral) + + if len(dihedrals) == 0: + return # No rotation required + + logger.info(f"Have {len(dihedrals)} dihedrals to rotate") + + coords = rotate( + py_coords=self.coordinates, + py_angles=np.zeros(len(dihedrals)), + py_axes=dihedrals.axes, + py_rot_idxs=dihedrals.rot_idxs, + py_origins=dihedrals.origins, + minimise=True, + py_rep_idxs=self.non_bonded_idx_matrix, + ) + + self.coordinates = coords + return None + + def _force_double_bond_stereochem(self, dihedral): + """ + For double bonds in rings (>8 members usually) stereochemistry needs to + be generated, but may not be possible, so minimise the energy under + the constraint defining the E/Z over a specific dihedral + + Z + | + X -----Y + / + W + + ----------------------------------------------------------------------- + Arguments: + dihedral (autode.smiles.builder.Dihedral): + """ + logger.info(f"Forcing stereochemistry for {dihedral}") + + if not ( + self.graph.edges[dihedral.mid_idxs]["order"] == 2 + and np.isclose(dihedral.phi0 % np.pi, 0) + ): + raise ValueError( + "Expecting a 0º or 180º dihedral for E/Z" + "over a double bond - cannot rotate" + ) + + # Get the bond lengths for the three bonds + r_wx = self.bonds.first_involving(*dihedral.idxs[:2]).r0 + r_xy = self.bonds.first_involving(*dihedral.mid_idxs).r0 + r_yz = self.bonds.first_involving(*dihedral.idxs[-2:]).r0 + + if np.isclose(dihedral.phi0, np.pi): + # Distance constraint for a trans double bond + r_wz = np.sqrt( + ((r_wx + r_yz) * np.sin(np.pi / 3.0)) ** 2 + + ((r_wx + r_yz) * np.cos(np.pi / 3.0) + r_xy) ** 2 + ) + + else: # and similarly for cis + r_wz = (r_wx + r_yz) * np.sin(np.pi / 6.0) + r_xy + + def c_cosine_rule(a, b, gamma): + """c = √a^2 + b^2 - 2ab cos(γ)""" + return np.sqrt(a**2 + b**2 - 2 * a * b * np.cos(gamma)) + + r_wy = c_cosine_rule(r_wx, r_xy, 2.0 * np.pi / 3.0) + r_xz = c_cosine_rule(r_xy, r_yz, 2.0 * np.pi / 3.0) + + # Apply distance constraints over the all the pairwise distances, + # such that the correct geometry is the only minimum (with just r_wz) + # constraints the WXY and XYZ angles change to accommodate r_wz, rather + # than there being any dihedral rotation) + dist_consts = { + dihedral.end_idxs: r_wz, + (dihedral.idxs[0], dihedral.idxs[2]): r_wy, + (dihedral.idxs[1], dihedral.idxs[3]): r_xz, + dihedral.mid_idxs: r_xy, + } + + self._ff_minimise(distance_constraints=dist_consts) + self._reset_queued_atom_sites(other_idxs=dihedral.mid_idxs) + return None + + def _queue_double_bond_dihedral(self, bond): + """ + For a double bond queue the dihedral rotation to be applied such that:: + + X -----Y + / | + W Z + + where the dihedral is 0 or π, depending on the stereochemistry + + ----------------------------------------------------------------------- + Arguments: + bond (autode.smiles.base.SMILESBond): + """ + idx_x, idx_y = bond + + nbrs_x = [idx for idx in self.atoms[idx_x].neighbours if idx != idx_y] + nbrs_y = [idx for idx in self.atoms[idx_y].neighbours if idx != idx_x] + + if len(nbrs_x) == 0 or len(nbrs_y) == 0: + logger.info( + f"At least one atom forming {bond} had no " + "neighbours - no need to rotate the dihedral" + ) + return + + # Remove any hydrogen atoms from the neighbours, as they are skipped + # when defining the stereochem + nbrs_x_noH = [idx for idx in nbrs_x if self.atoms[idx].label != "H"] + nbrs_y_noH = [idx for idx in nbrs_y if self.atoms[idx].label != "H"] + + if len(nbrs_x_noH) > 0: + nbrs_x = nbrs_x_noH + + if len(nbrs_y_noH) > 0: + nbrs_y = nbrs_y_noH + + # Index W is the closest atom index to X, that isn't Y + idx_w = nbrs_x[np.abs(np.array(nbrs_x) - idx_x).argmin()] + # and similarly for Z + idx_z = nbrs_y[np.abs(np.array(nbrs_y) - idx_y).argmin()] + + # Is this bond cis or trans? + stro_x, stro_y = ( + self.atoms[idx_x].stereochem, + self.atoms[idx_y].stereochem, + ) + + phi = np.pi # Default to a trans double bond + + if ( + ( + all( + self.atoms[idx].in_ring + for idx in (idx_w, idx_x, idx_y, idx_z) + ) + and not self.atoms[idx_x].has_stereochem + ) + or stro_x == stro_y == SMILESStereoChem.ALKENE_UP + or stro_x == stro_y == SMILESStereoChem.ALKENE_DOWN + ): + phi = 0 + + dihedral = SDihedral([idx_w, idx_x, idx_y, idx_z], phi0=phi) + + logger.info(f"Queuing {dihedral}") + self.queued_dihedrals.append(dihedral) + return None + + def _rotate_dihedrals(self): + """Rotate all dihedrals in the queue""" + if len(self.queued_dihedrals) == 0: + return # Nothing to be done + + logger.info(f"Have {len(self.queued_dihedrals)} dihedral(s) to rotate") + + for i, dihedral in enumerate(self.queued_dihedrals): + try: + dihedral.find_rot_idxs( + graph=self.graph.copy(), atoms=self.atoms + ) + + except FailedToSetRotationIdxs: + logger.warning(f"Could not apply rotation {dihedral}") + + if dihedral.needs_forcing(atoms=self.atoms): + logger.info( + "Dihedral is too far away from that defined " + "by the stereochemistry - forcing" + ) + self._force_double_bond_stereochem(dihedral) + + # Delete this dihedral, that has beed forced, and continue + del self.queued_dihedrals[i] + return self._rotate_dihedrals() + + dphis = [ + dihedral.phi0 - dihedral.value(self.atoms) + for dihedral in self.queued_dihedrals + ] + + self.coordinates = rotate( + py_coords=self.coordinates, + py_angles=np.array(dphis, dtype="f8"), + py_axes=self.queued_dihedrals.axes, + py_rot_idxs=self.queued_dihedrals.rot_idxs, + py_origins=self.queued_dihedrals.origins, + ) + + self.queued_dihedrals.clear() + self._reset_queued_atom_sites() + return None + + def _add_bonded_atoms(self, idx): + """ + Add all the atoms bonded to a particular index, that have not already + been shifted + + ----------------------------------------------------------------------- + Arguments: + idx (int): Atom index + """ + atom = self.atoms[idx] + + for bond in self.bonds.involving(idx): + bonded_idx = bond[0] if bond[1] == idx else bond[1] + + if bonded_idx in self.queued_atoms: + # Delete one of the empty sites + if atom.type.n_empty_sites > 0: + _ = atom.type.empty_site() + + self._close_ring(ring_bond=bond) + continue + + if self.atoms[bonded_idx].is_shifted: + # Dihedrals over double bonds need to be 0 or π, queue the + # rotation to be performed after all other atoms have been + # added + if bond.order == 2: + self._queue_double_bond_dihedral(bond) + + continue + + # Get an empty site on this atom. If this atom is chrial then + # there is no choice to minimise the repulsion with the rest of + # the structure + if atom.type.is_chiral: + site = atom.type.empty_site() + else: + site = atom.type.empty_site_mr( + atom.coord, other_coords=self.coordinates + ) + + # Coordinate of this atom is the current position shifted by + # the ideal distance in a direction of a empty coordination + # site on the atom + coord = bond.r0 * site + atom.coord + bonded_atom = self.atoms[bonded_idx] + bonded_atom.translate(coord) + + # Atoms that are not terminal need to be added to the queue + if not isinstance( + self.atoms[bonded_idx].type, atom_types.TerminalAtom + ): + # and the atom type rotated so an empty site is coincident + # with this atom + bonded_atom.type.rotate_empty_onto( + point=atom.coord, coord=bonded_atom.coord + ) + # and queue + self.queued_atoms.append(bonded_idx) + + return None + + def set_atoms_bonds(self, atoms, bonds): + """ + From a list of SMILESAtoms, and SMILESBonds set the required attributes + and convert all implicit hydrogens into explicit atoms + + ----------------------------------------------------------------------- + Arguments: + atoms (list(autode.smiles.base.SMILESAtom)): + + bonds (auode.smiles.base.SMILESBonds): + """ + if atoms is None or len(atoms) == 0: + raise SMILESBuildFailed("Cannot build a structure with no atoms") + + # Set attributes + self.atoms, self.bonds = atoms, bonds + self.graph = MolecularGraph() + self.queued_atoms = [] + self.queued_dihedrals = SDihedrals() + + self._explicit_all_hydrogens() + + # Add nodes for all the atom indexes, without attributes for e.g + # atomic symbol as a normal molecular graph would have + for i in range(self.n_atoms): + self.graph.add_node(i) + + # Set the ideal bond lengths and the graph edges + for bond in self.bonds: + idx_i, idx_j = bond + self.graph.add_edge(idx_i, idx_j, order=bond.order) + + bond.r0 = self.atoms.eqm_bond_distance(idx_i, idx_j) + + self._set_atom_types() + + # Add the first atom to the queue of atoms to be translated etc. + self.queued_atoms.append(0) + # perturb the first atom's coordinate slightly, such that it is treated + # as being shifted (built) + self.atoms[0].translate(vec=np.array([0.001, 0.001, 0.001])) + return None + + @log_time(prefix="Built 3D in:", units="ms") + def build(self, atoms, bonds): + """ + Build a molecule by iterating through all the atoms adding it and + each of it's neighbours. i.e. + + atoms = [C, H, H, H] + + 1. Add C at origin + 2. Add all neighbours + 3. Done + + atoms = [C, C, C, 8xH] + + 1. Add C at origin + 2. Add H3, C neighbours & update queued atoms to include the C + that has been translated but needs it's neighbours adding to it + + ---------------------------------------------------------------------- + Arguments: + atoms (list(autode.smiles.SMILESAtoms)): + + bonds (autode.smiles.SMILESBonds): + """ + self.set_atoms_bonds(atoms, bonds) + + while not self.built: + idx = self.queued_atoms.pop(0) + self._add_bonded_atoms(idx) + self._rotate_dihedrals() + + logger.info(f"Queue: {self.queued_atoms}") + + self._minimise_non_ring_dihedrals() + return None diff --git a/autodE/source/autode/smiles/parser.py b/autodE/source/autode/smiles/parser.py new file mode 100644 index 0000000000000000000000000000000000000000..4e412d2796f0ef1c7e328442fd0715b91cf799a1 --- /dev/null +++ b/autodE/source/autode/smiles/parser.py @@ -0,0 +1,613 @@ +""" +(Open)SMILES parser implemented based on + +1. http://opensmiles.org/ +2. https://en.wikipedia.org/wiki/Simplified_molecular-input_line-entry_system + +as of 03/2021 +""" +from typing import Optional, Dict, List, Set + +from autode.log import logger +from autode.utils import log_time +from autode.atoms import elements +from autode.exceptions import InvalidSmilesString +from autode.smiles.base import ( + SMILESAtom, + SMILESBond, + SMILESBonds, + RingBond, + SMILESStereoChem, + aromatic_symbols, + organic_symbols, + bond_order_symbols, +) + + +class Parser: + def __init__(self) -> None: + """SMILES Parser""" + + self._string = "" + + # Indexes of the characters in the SMILES string that have been parsed + self.parsed_idxs: Set[int] = set() + self.atoms: List[SMILESAtom] = [] + self.bonds = SMILESBonds() + + @property + def n_atoms(self): + """Number of atoms in parsed, not including implicit hydrogens""" + return len(self.atoms) + + @property + def n_bonds(self): + """Number of bonds parsed""" + return len(self.bonds) + + @property + def charge(self): + """Total charge on all the atoms""" + return sum(atom.charge for atom in self.atoms) + + @property + def mult(self): + """Approximate spin multiplicity (2S+1). For multiple unpaired + electrons will default to a singlet""" + + n_electrons = ( + sum([at.atomic_number for at in self.atoms]) - self.charge + ) + + # Atoms have implicit hydrogens, so add them + n_electrons += sum( + at.n_hydrogens if at.n_hydrogens is not None else 0 + for at in self.atoms + ) + + return (n_electrons % 2) + 1 + + @property + def parsed(self): + """Has the parser parsed every character of the SMILES string""" + return len(self.parsed_idxs) == len(self._string) + + def _check_smiles(self): + """Check the SMILES string for unsupported characters""" + present_invalid_chars = [ + char for char in (".", "*") if char in self._string + ] + + if len(present_invalid_chars) > 0: + raise InvalidSmilesString( + f"{self._string} had invalid characters:" + f"{present_invalid_chars}" + ) + + return None + + @property + def smiles(self): + """SMILES string being parsed""" + return self._string + + @smiles.setter + def smiles(self, string: str): + """Set the SMILES string for the parser and reset""" + self._string = str(string.strip()) # strip leading/trailing whitespace + self._check_smiles() + + # Reset all the defaults for the parser + self.parsed_idxs = set() + self.atoms = [] + self.bonds = SMILESBonds() + + def _parse_sq_bracket(self, string: str) -> None: + """ + Parse a section in a square bracket + + e.g. [C], [CH3], [Cu+2], [O-], [C@H] + """ + + if "(" in string or ")" in string: + raise InvalidSmilesString('Cannot parse branch in "[]" section') + + if len(string) == 0: + raise InvalidSmilesString('"[]" must contain something') + + elif len(string) == 1: + # Single element e.g. [C] i.e. string = 'C' + self.atoms.append(SMILESAtom(string, n_hydrogens=0)) + return + + # e.g. [Cu++], first two characters are an element + if string[:2] in elements: + label, rest = string[:2], string[2:] + + # e.g. [CH2] or [n+] + elif string[0] in elements or string[0] in aromatic_symbols: + label, rest = string[0], string[1:] + + # e.g. [999C] NOTE: SMILES does allow for isotopes, but they're not + # supported + else: + raise InvalidSmilesString(f'Unknown first item {string} in a "[]"') + + if len(rest) == 0: # e.g. [Cu] etc. + self.atoms.append(SMILESAtom(label, n_hydrogens=0)) + return + + if any(elem in rest for elem in elements if elem != "H"): + raise InvalidSmilesString(f"Only expecting hydrogens in {rest}") + + atom = SMILESAtom( + label=label, + n_hydrogens=atomic_n_hydrogens(rest), + charge=atomic_charge(rest), + stereochem=atomic_sterochem(rest), + atom_class=atomic_class(rest), + ) + + self.atoms.append(atom) + return None + + def _parse_next_sq_bracket(self, idx: int) -> None: + """ + Parse the next square bracket section from the SMILES e.g. + + CCC [ CH3] + ^ + | + idx + + ----------------------------------------------------------------------- + Arguments: + idx (int): Position in the SMILES string for the [ + """ + if idx == len(self.smiles) - 1: + raise InvalidSmilesString( + '"[" cannot appear at ' "the end of a SMILES string" + ) + + # Split the on closed square brackets e.g + # [C -> ['C'] [C] -> ['C', ''] [CH4] -> ['CH4', ''] + closing_brackets_sec = self.smiles[idx + 1 :].split("]") + + if len(closing_brackets_sec) == 1: + raise InvalidSmilesString('Bracket "]" not closed') + + # [C] -> 'C', [CH4] -> 'CH4' + bracketed_sec = closing_brackets_sec[0] + self._parse_sq_bracket(bracketed_sec) + + # Have now parsed i+1 -- n_bracket_chars+1 inclusive + # where the +1 is from the final ] + self.parsed_idxs.update(list(range(idx, idx + len(bracketed_sec) + 2))) + return None + + def _parse_ring_idx(self, idx: int) -> int: + """ + From a position in the SMILES string determine the ring index, zero + indexed + e.g.:: + + C1CC... --> 0 + ^ + i + + C%99CC... --> 98 + ^ + i + + ----------------------------------------------------------------------- + Arguments: + idx (int): + + Returns: + (int): + """ + curr_char = self._string[idx] + + if curr_char.isdigit(): + return int(curr_char) - 1 + + if curr_char == "%": + if len(self._string[idx + 1 :]) < 2: + raise InvalidSmilesString("No ring index found following %") + + try: + return int(self._string[idx + 1 : idx + 3]) - 1 + + except ValueError: + raise InvalidSmilesString("Integer >9 not found following %") + + raise InvalidSmilesString( + f"Could not get the ring index {curr_char} " + f"was neither a number or a %" + ) + + def _add_bond(self, symbol, idx, prev_atom_idx=None): + """ + Add a bond to the list of bonds from the previously added atom to + + ----------------------------------------------------------------------- + Arguments: + symbol (str): Symbol of this bond e.g. # for a double bond, see + bond_order_symbols + + idx (int): Index of the position in the SMILES string + + Keyword Arguments: + prev_atom_idx (int | None): Index to bond the added atom to + """ + if self.n_atoms == 1: # First atom, thus no bonds to add + return + + if prev_atom_idx is None: + prev_atom_idx = self.n_atoms - 2 + + self.bonds.append( + SMILESBond(prev_atom_idx, self.n_atoms - 1, symbol=symbol) + ) + + if symbol == "=": + self._set_double_bond_stereochem(idx) + return None + + def _set_double_bond_stereochem(self, idx): + """ + Set the stereochemistry for the atoms involved in a double bond (E/Z + or cis/trans) that has just been added to the system e.g.:: + + C(/F)=C/F + ^ + | + idx + + where the slashes refer to the "up-ness" or "down-ness" of each single + bond is relative to the carbon atom + + ----------------------------------------------------------------------- + Arguments: + idx (int): Index of the current position in the SMILES string + """ + if "/" not in self._string and "\\" not in self._string: + # No defined double bond setereochemistry + return + + # Index that has been added previously and the new one + atom_idx_j, atom_idx_i = self.bonds[-1] + + # Now set the up or down-ness of the atoms that are bonded with a + # double bond, with respect to the next (or previous) atom + for char in self._string[idx:]: + if char == "/": + self.atoms[atom_idx_i].stereochem = SMILESStereoChem.ALKENE_UP + break + + if char == "\\": + self.atoms[ + atom_idx_i + ].stereochem = SMILESStereoChem.ALKENE_DOWN + break + + # Parse backwards from the final atom to assign the stereochemistry of + # atom_j. Needs to allow for branching e.g. C(\F)=C/F is trans + branched = False + + for char in self._string[:idx][::-1]: + if char == ")": # Generated a branch + branched = True + + if char == "(": # Closed a branch + branched = False + + if char == "\\": + self.atoms[atom_idx_j].stereochem = ( + SMILESStereoChem.ALKENE_UP + if not branched + else SMILESStereoChem.ALKENE_DOWN + ) + break + + if char == "/": + self.atoms[atom_idx_j].stereochem = ( + SMILESStereoChem.ALKENE_DOWN + if not branched + else SMILESStereoChem.ALKENE_UP + ) + break + + return None + + def _set_implicit_hs(self): + """ + Given a completely parsed set of atoms from a SMILES string set the + implicit hydrogens for all atoms where they're defined. From ref [1] + elems_pos_val is defined + + NOTE: Elements with implicit hydrogens must be neutral + """ + elems_poss_val = { + "B": (3,), + "C": (4,), + "N": (3, 5), + "O": (2,), + "P": (3, 5), + "S": (2, 4, 6), + "F": (1,), + "Cl": (1,), + "Br": (1,), + "I": (1,), + # Aromatic atoms are distinct + "b": (2,), + "c": (3,), + "n": (2,), + "o": (1,), + "p": (2,), + "s": (1,), + "se": (1,), + "as": (2,), + } + + for idx, atom in enumerate(self.atoms): + # Only consider atoms with undefined number of hydrogens + if atom.n_hydrogens is not None: + continue + + if atom.smiles_label not in elems_poss_val.keys(): + raise InvalidSmilesString( + "Could not define implicit hydrogens" f"for {atom.label}" + ) + + bonds = self.bonds.involving(idx) + + if not atom.is_aromatic: + atom._is_pi = any(bond.order > 1 for bond in bonds) + + sum_bond_orders = sum(bond.order for bond in bonds) + + # If the sum of the bond order is less than the minimum valance + # then add the appropriate number of hydrogens to satisfy the + # implicit valance + for valance in elems_poss_val[atom.smiles_label]: + if sum_bond_orders <= valance: + atom.n_hydrogens = valance - sum_bond_orders + break + + atom.n_hydrogens = 0 + + return None + + @log_time(prefix="Parsed SMILES in:", units="ms") + def parse(self, smiles: str): + """ + Parse a SMILES string e.g. '[He]', 'C' + """ + self.smiles = smiles + logger.info(f"Parsing {self.smiles}") + + branch_idxs = [] # Indexes of branch points + unclosed_bonds: Dict[int, RingBond] = {} # Bonds that must be closed + prev_idx = None # Index of the previous atom to bond the next to + + # Enumerate over the string until all characters have been parsed + for i, char in enumerate(self._string): + # Determine the type of bond the next added atom is bonded with + if i > 0 and self._string[i - 1] in bond_order_symbols: + bond_symbol = self._string[i - 1] # double, triple etc. + else: + bond_symbol = "-" # single bonds implicit + + # Skip any parsed atoms, bond order chars and cis/trans definitions + if i in self.parsed_idxs or char in bond_order_symbols + [ + "/", + "\\", + ]: + continue + + # Integer for a dangling bond e.g. C1, C=1, N3 etc. + elif char.isdigit() or char == "%": + ring_idx = self._parse_ring_idx(idx=i) + + # This bond is in the dictionary and can be closed and removed + if ring_idx in unclosed_bonds.keys(): + ring_bond = unclosed_bonds.pop(ring_idx) + ring_bond.close(prev_idx, symbol=bond_symbol) + self.atoms[-1].invert_stereochem() + + self.bonds.insert(ring_bond.bond_idx, ring_bond) + continue + + unclosed_bonds[ring_idx] = RingBond( + idx_i=prev_idx, + symbol=bond_symbol, + bond_idx=len(self.bonds), + ) + + # Any square bracketed atom with hydrogens defined e.g. [OH], [Fe] + elif char == "[": + self._parse_next_sq_bracket(idx=i) + + elif char == "(": # New branch + if i != 0 and self._string[i - 1] == ")": + # Directly opened a new branch so keep the previous index + pass + else: + branch_idxs.append(self.n_atoms - 1) + continue + + elif char == ")": # Closed branch + if len(branch_idxs) == 0: + raise InvalidSmilesString('Closed unopened bracket "("') + + # If the next character is another branch from the same atom + # then the branch index should not be deleted + prev_idx = branch_idxs[-1] + + if next_char(self._string, i) != "(": + del branch_idxs[-1] + + continue + + # only Cl, Br + elif char + next_char(self._string, i) in ("Cl", "Br"): + atom = SMILESAtom(label=char + self._string[i + 1]) + self.atoms.append(atom) + # Have also parsed the next character + self.parsed_idxs.update([i, i + 1]) + + # e.g. C, B, O + elif char in organic_symbols + aromatic_symbols: + self.atoms.append(SMILESAtom(label=char)) + + else: + raise InvalidSmilesString(f"Unsupported character {char}") + + # Finally add the bond and add this character to those parsed + self._add_bond(bond_symbol, idx=i, prev_atom_idx=prev_idx) + self.parsed_idxs.add(i) + + # Reset the index of the previous atom, so the next atom + # will be bonded to the previously added one (unless a branch has + # been closed) + prev_idx = self.n_atoms - 1 + + if len(unclosed_bonds) > 0: + raise InvalidSmilesString("Found unclosed rings") + + self._set_implicit_hs() + return None + + +def atomic_charge(string): + """ + Parse a section of a SMILES string associated with an atom for the + formal charge on the atom, will ignore anything but +, -. e.g.:: + + + -> 1 + - -> -1 + ++ -> 2 + H+ -> 1 + + --------------------------------------------------------------------------- + Returns: + (int): charge + """ + charge = 0 + for i, item in enumerate(string): + if item == "+": + sign = 1 + + elif item == "-": + sign = -1 + + else: # Not a charge determining portion + continue + + # +3 or +2 or -2 etc. + if next_char(string, i).isdigit(): + return sign * int(string[i + 1]) + + # ++ or -- + elif next_char(string, i) in ("+", "-"): + return sign * 2 + + # just - or +, thus the charge is just the sign + else: + return sign + + return charge + + +def atomic_sterochem(string): + """ + Extract the first occurring atomic stereochemistry from a partial + SMILES i.e:: + + @H3 -> @ + @ -> @ + @@- -> @@ + + --------------------------------------------------------------------------- + Arguments: + string (str): + + Returns: + (SMILESStereoChem): Type of point stereochemistry + """ + for i, item in enumerate(string): + if item == "@": + if next_char(string, i) == "@": + return SMILESStereoChem.TET_INVERTED + + return SMILESStereoChem.TET_NORMAL + + return SMILESStereoChem.NONE + + +def atomic_n_hydrogens(string): + """ + Extract the number of hydrogens from a partial SMILES, i.e.:: + + H3- -> 3 + H -> 1 + C -> 0 + + --------------------------------------------------------------------------- + Arguments: + string (str): + + Returns: + (int): Number of hydrogens + """ + for i, item in enumerate(string): + if item == "H": + # e.g. [CH3] where rest = H3 or [OH-] + if next_char(string, i).isdigit(): + return int(string[i + 1]) + + # e.g. [OH] + else: + return 1 + + return 0 + + +def atomic_class(string: str) -> Optional[int]: + """Extract the atomic class from a string i.e.:: + + H4:2 -> 2 + :7 -> 7 + :001 -> 1 + """ + + if ":" not in string: + return None + + digits = string.split(":")[1] + + try: + return int(digits) + + except ValueError: + raise InvalidSmilesString("") + + +def next_char(string: str, idx: int) -> str: + """ + Get the next character in a string if it exists otherwise return + an empty string + + --------------------------------------------------------------------------- + Arguments: + string: + idx: Index of the current position in the string + + Returns: + Next character in the string + """ + if idx >= len(string) - 1: + return "" + + return string[idx + 1] diff --git a/autodE/source/autode/smiles/smiles.py b/autodE/source/autode/smiles/smiles.py new file mode 100644 index 0000000000000000000000000000000000000000..aa65ede701f103ed9ae38d11866dcaa539ddde99 --- /dev/null +++ b/autodE/source/autode/smiles/smiles.py @@ -0,0 +1,187 @@ +from rdkit.Chem import AllChem +from rdkit.Chem.Descriptors import NumRadicalElectrons +from rdkit import Chem +from autode.conformers.conf_gen import get_simanl_atoms +from autode.conformers.conformers import atoms_from_rdkit_mol +from autode.exceptions import RDKitFailed, SMILESBuildFailed +from autode.log import logger +from autode.mol_graphs import make_graph +from autode.smiles import Parser, Builder + + +def calc_multiplicity(molecule, n_radical_electrons): + """Calculate the spin multiplicity 2S + 1 where S is the number of + unpaired electrons. Will only override non-default (unity multiplicity) + + --------------------------------------------------------------------------- + Arguments: + molecule (autode.molecule.Molecule): + + Keyword Arguments: + n_radical_electrons (int | None): + + Returns: + (int): multiplicity of the molecule + """ + + if molecule.mult == 1 and n_radical_electrons == 1: + # Cannot have multiplicity = 1 and 1 radical electrons – override + # default multiplicity + return 2 + + if molecule.mult == 1 and n_radical_electrons > 1: + logger.warning( + "Diradicals by default singlets. Set mol.mult if it's " + "any different" + ) + return 1 + + return molecule.mult + + +def init_organic_smiles(molecule, smiles): + """ + Initialise a molecule from a SMILES string, set the charge, multiplicity ( + if it's not already specified) and the 3D geometry using RDKit + + --------------------------------------------------------------------------- + Arguments: + molecule (autode.molecule.Molecule): + + smiles (str): SMILES string + """ + parser, builder = Parser(), Builder() + + parser.parse(smiles) + builder.set_atoms_bonds(atoms=parser.atoms, bonds=parser.bonds) + + # RDKit struggles with large rings or single atoms + if builder.max_ring_n >= 8 or builder.n_atoms == 1: + logger.info("Falling back to autodE builder") + + # TODO: currently the SMILES is parsed twice, which is not ideal + return init_smiles(molecule=molecule, smiles=smiles) + + try: + rdkit_mol = Chem.MolFromSmiles(smiles) + + if rdkit_mol is None: + logger.warning("RDKit failed to initialise a molecule") + return init_smiles(molecule, smiles) + + rdkit_mol = Chem.AddHs(rdkit_mol) + + except RuntimeError: + raise RDKitFailed + + logger.info("Using RDKit to initialise") + + molecule.charge = Chem.GetFormalCharge(rdkit_mol) + molecule.mult = calc_multiplicity(molecule, NumRadicalElectrons(rdkit_mol)) + + bonds = [ + (bond.GetBeginAtomIdx(), bond.GetEndAtomIdx()) + for bond in rdkit_mol.GetBonds() + ] + + # Generate a single 3D structure using RDKit's ETKDG conformer generation + # algorithm + method = AllChem.ETKDGv2() + method.randomSeed = 0xF00D + AllChem.EmbedMultipleConfs(rdkit_mol, numConfs=1, params=method) + molecule.atoms = atoms_from_rdkit_mol(rdkit_mol, conf_id=0) + + # Revert to RR if RDKit fails to return a sensible geometry + if not molecule.has_reasonable_coordinates: + molecule.rdkit_conf_gen_is_fine = False + molecule.atoms = get_simanl_atoms(molecule, save_xyz=False) + + for atom, _ in Chem.FindMolChiralCenters(rdkit_mol): + molecule.graph.nodes[atom]["stereo"] = True + + for bond in rdkit_mol.GetBonds(): + idx_i, idx_j = bond.GetBeginAtomIdx(), bond.GetEndAtomIdx() + + if bond.GetBondType() != Chem.rdchem.BondType.SINGLE: + molecule.graph.edges[idx_i, idx_j]["pi"] = True + + if bond.GetStereo() != Chem.rdchem.BondStereo.STEREONONE: + molecule.graph.nodes[idx_i]["stereo"] = True + molecule.graph.nodes[idx_j]["stereo"] = True + + for atom, smiles_atom in zip(molecule.atoms, parser.atoms): + atom.atom_class = smiles_atom.atom_class + + make_graph(species=molecule, bond_list=bonds) + check_bonds(molecule, bonds=rdkit_mol.GetBonds()) + + molecule.rdkit_mol_obj = rdkit_mol + return None + + +def init_smiles(molecule, smiles): + """ + Initialise a molecule from a SMILES string + + --------------------------------------------------------------------------- + Arguments: + molecule (autode.molecule.Molecule): + + smiles (str): SMILES string + """ + molecule.rdkit_conf_gen_is_fine = False + + parser, builder = Parser(), Builder() + + parser.parse(smiles) + molecule.charge = parser.charge + + # Only override the default multiplicity (1) with the parser-defined value + if molecule.mult == 1: + molecule.mult = parser.mult + + try: + builder.build(atoms=parser.atoms, bonds=parser.bonds) + molecule.atoms = builder.canonical_atoms + + except (SMILESBuildFailed, NotImplementedError): + molecule.atoms = builder.canonical_atoms_at_origin + + for idx, atom in enumerate(builder.atoms): + if atom.has_stereochem: + molecule.graph.nodes[idx]["stereo"] = True + + make_graph(molecule, bond_list=parser.bonds) + check_bonds(molecule, bonds=parser.bonds) + + for bond in parser.bonds: + molecule.graph.edges[tuple(bond)]["pi"] = True + + if not molecule.has_reasonable_coordinates: + logger.warning( + "3D builder did not make a sensible geometry, " + "Falling back to random minimisation." + ) + molecule.atoms = get_simanl_atoms(molecule, save_xyz=False) + + return None + + +def check_bonds(molecule, bonds): + """ + Ensure the SMILES string and the 3D structure have the same bonds, + but don't override + + --------------------------------------------------------------------------- + Arguments: + molecule (autode.molecule.Molecule): + + bonds (list): + """ + check_molecule = molecule.copy() + make_graph(check_molecule) + + if len(bonds) != check_molecule.graph.number_of_edges(): + logger.warning("Bonds and graph do not match") + + return None diff --git a/autodE/source/autode/solvent/__init__.py b/autodE/source/autode/solvent/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..cae7cef983bd7f39b09e6e8d2eaa2034bf4e4a16 --- /dev/null +++ b/autodE/source/autode/solvent/__init__.py @@ -0,0 +1,5 @@ +from autode.solvent.solvents import Solvent, ImplicitSolvent, get_solvent +from autode.solvent.explicit_solvent import ExplicitSolvent + + +__all__ = ["get_solvent", "Solvent", "ImplicitSolvent", "ExplicitSolvent"] diff --git a/autodE/source/autode/solvent/explicit_solvent.py b/autodE/source/autode/solvent/explicit_solvent.py new file mode 100644 index 0000000000000000000000000000000000000000..238d0e2b3270aea1db8d2b20d5e79a4fa93f9fcd --- /dev/null +++ b/autodE/source/autode/solvent/explicit_solvent.py @@ -0,0 +1,275 @@ +import numpy as np +from typing import Optional, TYPE_CHECKING, Any, List +from scipy.spatial import distance_matrix + +from autode.geom import get_points_on_sphere, get_rot_mat_euler +from autode.log import logger +from autode.atoms import AtomCollection +from autode.solvent.solvents import Solvent + +if TYPE_CHECKING: + from autode.species.species import Species + + +class _RandomPointGenerator: + r""" + Generator for points (unit vectors) in solvent shells. e.g. where if x is + a solute molecule the vectors in the different shells:: + + ------ + ` + --- ` + \ | + x | | + + """ + + def __init__(self, random_state: np.random.RandomState): + """ + Point generator + + Arguments: + random_state (numpy.random.mtrand.RandomState): + """ + self.random_state = random_state + self._sphere_n = 1 + self._points: List[np.ndarray] = [] + + def random_point(self) -> np.ndarray: + """ + Generate a random point in a solvent shell. Will return points on + the surface of the solvent shell (self._sphere_n) and increment the + solvent shell when there are none left + + Returns: + (np.ndarray): Point on the 3D sphere + """ + + if len(self._points) == 0: + # Surface area of the sphere scales r^2, so square solvent shell + self._points = get_points_on_sphere( + n_points=self._sphere_n**2 * 10 + ) + self._sphere_n += 1 + + idx = self.random_state.randint(0, len(self._points)) + return self._points.pop(idx) + + +class ExplicitSolvent(AtomCollection, Solvent): + """Explicit solvation""" + + def __init__( + self, + solvent: "Species", + num: int, + solute: Optional["Species"] = None, + **kwargs, + ): + """ + Explicit solvent. Initial construction attempts to generate a + reasonable distribution around the (unmodified) solute. Only supports + unicomponent uncharged solvents. + + ---------------------------------------------------------------------- + Arguments: + + solvent (autode.species.species.Species): Solvent molecule (copied) + + num (int): Number of solvent molecules to add + + + Keyword Arguments: + + solute (autode.species.species.Species | None): Solute which this + solvent surrounds. If None then no translation to the + explicit solvent molecules will be applied + + aliases (list(str)): List of aliases of this solvent + """ + if num <= 0: + raise ValueError( + "Must solvate with at least a single solvent " + f"molecule. Had {num}" + ) + + solvent_atoms = sum((solvent.atoms.copy() for _ in range(num)), None) # type: ignore + AtomCollection.__init__(self, atoms=solvent_atoms) + Solvent.__init__( + self, + name=solvent.name, + smiles=None, + aliases=kwargs.get("aliases", None), + ) + + self.solvent_n_atoms = solvent.n_atoms + # TODO: Something better than this hardcoded value + self.solvent_radius = solvent.radius.to("ang") + 2.0 + + if solute is not None: + self.randomise_around(solute) + + def __eq__(self, other: Any) -> bool: + """Equality between two explicit solvent environments""" + + if ( + isinstance(other, ExplicitSolvent) + and self.n_atoms == other.n_atoms + ): + assert self.atoms and other.atoms # keep mypy happy + return all( + o_at.label == at.label + for o_at, at in zip(other.atoms, self.atoms) + ) + + return False + + @property + def is_implicit(self) -> bool: + """Is this solvent implicit? + + Returns: + (bool): False + """ + return False + + @property + def n_solvent_molecules(self) -> int: + """Number of solvent molecules comprising this explicit solvent + cluster + + Returns: + (int): n + """ + return self.n_atoms // self.solvent_n_atoms + + def solvent_atom_idxs(self, i: int) -> np.ndarray: + """ + Atom indexes of an particular solvent molecule + + Returns: + (np.ndarray): Atom indexes + """ + if i < 0 or i >= self.n_solvent_molecules: + raise ValueError( + f"Cannot find the indexes for the {i}th solvent " + f"only had {self.n_solvent_molecules}." + ) + + first_idx = i * self.solvent_n_atoms + last_idx = first_idx + self.solvent_n_atoms + + return np.array(range(first_idx, last_idx), dtype=int) + + @staticmethod + def _too_close_to_solute( + solvent_coords: np.ndarray, + solute_coords: np.ndarray, + solute_radius: float, + ) -> bool: + """ + Are a set of solvent coordinates too close to the solute? (for a + particular solute radius) + + Arguments: + solvent_coords (np.ndarray): Shape = (N, 3) + + solute_coords (np.ndarray): Shape = (M, 3) + + solute_radius (float): Radius (Å) + """ + min_dist = np.min(distance_matrix(solute_coords, solvent_coords)) + return min_dist < solute_radius + + def _too_close_to_solvent( + self, coords: np.ndarray, solvent_idxs: np.ndarray, max_idx: int + ) -> bool: + """ + Are a set of solvent coordinates too close to the solvent molecules + that have already been translated? + + Arguments: + coords (np.ndarray): Shape = (N, 3) Coordinates of all + the solvent molecules + + solvent_idxs (np.ndarray): Integer array of atom indexes of a + particular solvent molecule + + max_idx (int): Indexes up to which the repulsion should be + calculated. NOT INCLUSIVE + """ + if max_idx == 0: + return False + + min_dist = np.min( + distance_matrix( + coords[solvent_idxs], coords[: max_idx * self.solvent_n_atoms] + ) + ) + + return min_dist < self.solvent_radius + + def randomise_around(self, solute: "Species") -> None: + r""" + Randomise the positions of the solvent molecules around the solute, + for example using a methane solute and water solvent:: + + + H2O + H20 + H2o H2O + H2O + H2O CH4 H2O + + H2O H2O + + + where the solvent molecules are roughly packed in shells around the + solute. + + Arguments: + solute (autode.species.species.Species): + """ + logger.info( + f"Randomising {self.n_solvent_molecules} solvent " + f"molecules around {solute}" + ) + + coords = self.coordinates + assert ( + coords is not None + ), "Must have coordinates to populate solvent around" + + # ----------------- Properties of the solute molecule ----------------- + m_radius = solute.radius.to("ang") + 1.0 # Assume some exterior H + m_origin = np.average(solute.coordinates, axis=0) + m_coords = solute.coordinates - m_origin + # --------------------------------------------------------------------- + + rand = np.random.RandomState() + pg = _RandomPointGenerator(random_state=rand) + + for i in range(self.n_solvent_molecules): + idxs = self.solvent_atom_idxs(i) + coords[idxs] -= np.average(coords[idxs], axis=0) # -> origin + + # Apply a random rotation to the solvent molecule + rand_rot_mat = get_rot_mat_euler( + axis=rand.uniform(-1.0, 1.0, size=3), + theta=rand.uniform(-np.pi, np.pi), + ) + + coords[idxs] = np.dot(coords[idxs], rand_rot_mat.T) + + # Select a random vector along which this solvent molecule is to be + # translated until there is not any close contacts + vec = 0.1 * pg.random_point() + + while self._too_close_to_solute( + coords[idxs], m_coords, m_radius + ) or self._too_close_to_solvent(coords, idxs, i): + coords[idxs] += vec + + # Finally, translate to be centred around the solute's origin + self.coordinates = coords + m_origin + return None diff --git a/autodE/source/autode/solvent/lib/1,1,1-trichloroethane.xyz b/autodE/source/autode/solvent/lib/1,1,1-trichloroethane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..9bdcfb60cbcdc793cf1de871532bac30e06552be --- /dev/null +++ b/autodE/source/autode/solvent/lib/1,1,1-trichloroethane.xyz @@ -0,0 +1,10 @@ +8 +Generated by autodE on: 2021-10-13. E = -1457.581688 Ha +C -0.80105 0.07876 -0.26127 +C 0.62710 -0.06102 0.20243 +Cl 1.55876 -1.01879 -0.97368 +Cl 1.38067 1.54339 0.36475 +Cl 0.67057 -0.87901 1.78439 +H -0.82393 0.62117 -1.21575 +H -1.37371 0.63526 0.49245 +H -1.23850 -0.91976 -0.39323 diff --git a/autodE/source/autode/solvent/lib/1,1,2-trichloroethane.xyz b/autodE/source/autode/solvent/lib/1,1,2-trichloroethane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..17ae715d3e81a84f5452ec1632ce2c1ef18d16bf --- /dev/null +++ b/autodE/source/autode/solvent/lib/1,1,2-trichloroethane.xyz @@ -0,0 +1,10 @@ +8 +Generated by autodE on: 2021-10-13. E = -1457.583711 Ha +Cl 1.85690 1.10608 0.04843 +C 0.72828 -0.24441 -0.17148 +C -0.67450 0.14602 0.23946 +Cl -1.33504 1.40761 -0.81177 +Cl -1.68683 -1.31513 0.20075 +H 0.73791 -0.55403 -1.22588 +H 1.06785 -1.08007 0.45448 +H -0.69457 0.53402 1.26600 diff --git a/autodE/source/autode/solvent/lib/1,2,4-trimethylbenzene.xyz b/autodE/source/autode/solvent/lib/1,2,4-trimethylbenzene.xyz new file mode 100644 index 0000000000000000000000000000000000000000..ecdbd912a5fb8512a94db1b04aaec30e6f7b7d1b --- /dev/null +++ b/autodE/source/autode/solvent/lib/1,2,4-trimethylbenzene.xyz @@ -0,0 +1,23 @@ +21 +Generated by autodE on: 2021-10-13. E = -349.544878 Ha +C 3.11604 0.61142 -0.33243 +C 1.70517 0.11632 -0.19187 +C 1.39017 -1.24025 -0.29240 +C 0.06948 -1.67295 -0.18691 +C -0.97812 -0.77672 0.02798 +C -2.39111 -1.26084 0.16406 +C -0.67490 0.59560 0.13882 +C -1.76521 1.59325 0.39218 +C 0.65243 1.01231 0.02279 +H 3.84493 -0.19666 -0.17670 +H 3.33800 1.41043 0.39001 +H 3.29247 1.03154 -1.33753 +H 2.18601 -1.97096 -0.46167 +H -0.15533 -2.74026 -0.27415 +H -2.47331 -2.32944 -0.08015 +H -3.07933 -0.71140 -0.49609 +H -2.76702 -1.12037 1.19221 +H -1.36810 2.61759 0.41877 +H -2.54830 1.55005 -0.38219 +H -2.26964 1.40068 1.35436 +H 0.87547 2.08076 0.11103 diff --git a/autodE/source/autode/solvent/lib/1,2-dibromoethane.xyz b/autodE/source/autode/solvent/lib/1,2-dibromoethane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..b30c0f523fbddcb6b66792130e744d904e76641f --- /dev/null +++ b/autodE/source/autode/solvent/lib/1,2-dibromoethane.xyz @@ -0,0 +1,10 @@ +8 +Generated by autodE on: 2021-10-13. E = -5225.565312 Ha +Br -1.84491 1.25614 -0.24813 +C -0.66133 -0.15295 0.35759 +C 0.67625 -0.16253 -0.33120 +Br 1.71494 1.44310 -0.01145 +H -0.55588 -0.02301 1.44232 +H -1.18469 -1.09870 0.15434 +H 1.27872 -0.99688 0.05632 +H 0.57690 -0.26517 -1.41968 diff --git a/autodE/source/autode/solvent/lib/1,2-dichlorobenzene.xyz b/autodE/source/autode/solvent/lib/1,2-dichlorobenzene.xyz new file mode 100644 index 0000000000000000000000000000000000000000..4f24eecdf41193a0e0abffe744c72105cbc3abb7 --- /dev/null +++ b/autodE/source/autode/solvent/lib/1,2-dichlorobenzene.xyz @@ -0,0 +1,14 @@ +12 +Generated by autodE on: 2021-10-13. E = -1150.428874 Ha +Cl 2.65733 0.04016 -1.47147 +C 1.14176 0.02734 -0.65554 +C 0.01632 0.57017 -1.27513 +C -1.21258 0.56338 -0.62262 +C -1.32299 0.01538 0.65432 +C -0.20334 -0.52513 1.27792 +C 1.02921 -0.52311 0.62681 +Cl 2.40471 -1.20251 1.40699 +H 0.12306 0.99590 -2.27381 +H -2.08634 0.99122 -1.11766 +H -2.28366 0.00620 1.17319 +H -0.26339 -0.95919 2.27679 diff --git a/autodE/source/autode/solvent/lib/1,2-dichloroethane.xyz b/autodE/source/autode/solvent/lib/1,2-dichloroethane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..00f22ece14104aa27b9de0433569a7abeb1dbefd --- /dev/null +++ b/autodE/source/autode/solvent/lib/1,2-dichloroethane.xyz @@ -0,0 +1,10 @@ +8 +Generated by autodE on: 2021-10-13. E = -998.278862 Ha +Cl -1.34436 1.60531 -0.15504 +C -0.74965 -0.04516 0.14129 +C 0.70789 -0.19773 -0.20886 +Cl 1.75776 0.78951 0.83144 +H -0.92801 -0.28102 1.19897 +H -1.34844 -0.72544 -0.48031 +H 0.89576 0.10252 -1.24943 +H 1.00886 -1.24799 -0.07795 diff --git a/autodE/source/autode/solvent/lib/1,2-ethanediol.xyz b/autodE/source/autode/solvent/lib/1,2-ethanediol.xyz new file mode 100644 index 0000000000000000000000000000000000000000..3bd4f97f02525c467b1056f774c37785eafba067 --- /dev/null +++ b/autodE/source/autode/solvent/lib/1,2-ethanediol.xyz @@ -0,0 +1,12 @@ +10 +Generated by autodE on: 2021-10-13. E = -229.824156 Ha +O -1.73676 0.38665 -0.65501 +C -0.54331 0.45389 0.07073 +C 0.54345 -0.45490 -0.48656 +O 1.73819 -0.38463 0.23665 +H -2.09831 -0.50026 -0.56059 +H -0.68338 0.22402 1.14661 +H -0.19852 1.50165 0.00980 +H 0.20032 -1.50302 -0.42284 +H 0.68069 -0.22639 -1.56314 +H 2.09763 0.50298 0.14054 diff --git a/autodE/source/autode/solvent/lib/1-bromo-2-methylpropane.xyz b/autodE/source/autode/solvent/lib/1-bromo-2-methylpropane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..76ea6e98ed055f69f9822a62c9894dfb54260329 --- /dev/null +++ b/autodE/source/autode/solvent/lib/1-bromo-2-methylpropane.xyz @@ -0,0 +1,16 @@ +14 +Generated by autodE on: 2021-10-13. E = -2731.100206 Ha +C 0.62555 0.32999 -1.30870 +C -0.12641 -0.19134 -0.09246 +C -1.42765 0.57168 0.14582 +C 0.68882 -0.12324 1.18986 +Br 2.35520 -1.12449 1.13594 +H 0.91093 1.38801 -1.18163 +H -0.00017 0.25462 -2.21125 +H 1.54564 -0.24565 -1.48254 +H -0.38114 -1.25042 -0.27607 +H -1.22727 1.62431 0.40931 +H -2.05445 0.56529 -0.75874 +H -2.01298 0.12731 0.96545 +H 0.97446 0.91139 1.42807 +H 0.12929 -0.54045 2.03694 diff --git a/autodE/source/autode/solvent/lib/1-bromooctane.xyz b/autodE/source/autode/solvent/lib/1-bromooctane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..1da87c42b64c9dca66aee271ed7811f9aed2bd1b --- /dev/null +++ b/autodE/source/autode/solvent/lib/1-bromooctane.xyz @@ -0,0 +1,28 @@ +26 +Generated by autodE on: 2021-10-13. E = -2888.056806 Ha +C -3.65323 -0.66116 -0.47790 +C -3.00609 0.69525 -0.71680 +C -1.59107 0.81600 -0.15799 +C -0.54548 -0.09372 -0.79568 +C 0.73408 -0.24025 0.02270 +C 1.50155 1.05772 0.24399 +C 2.70283 0.92986 1.17311 +C 3.71503 -0.13710 0.81623 +Br 4.45219 0.07364 -0.97925 +H -4.69219 -0.67905 -0.84039 +H -3.11768 -1.47181 -0.99362 +H -3.66889 -0.90827 0.59660 +H -3.00373 0.92547 -1.79725 +H -3.62872 1.47645 -0.24942 +H -1.63014 0.60005 0.92716 +H -1.26303 1.86538 -0.24915 +H -0.29726 0.27834 -1.80611 +H -0.97210 -1.09951 -0.94012 +H 1.38958 -0.96649 -0.48577 +H 0.47879 -0.68741 1.00277 +H 1.83872 1.45312 -0.72991 +H 0.83138 1.82118 0.67065 +H 2.34729 0.68305 2.19159 +H 3.21687 1.90198 1.24856 +H 4.58113 -0.11239 1.48877 +H 3.28006 -1.14483 0.83733 diff --git a/autodE/source/autode/solvent/lib/1-bromopentane.xyz b/autodE/source/autode/solvent/lib/1-bromopentane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..4b6e4e95fd28ae7125358cee12d228da7697618c --- /dev/null +++ b/autodE/source/autode/solvent/lib/1-bromopentane.xyz @@ -0,0 +1,19 @@ +17 +Generated by autodE on: 2021-10-13. E = -2770.338731 Ha +C -2.60582 0.05402 0.19001 +C -1.22498 -0.41641 0.62162 +C -0.08356 0.20214 -0.17363 +C 1.29303 -0.26609 0.28789 +C 2.40821 0.36283 -0.51577 +Br 4.17640 -0.18731 0.08831 +H -2.71868 1.14231 0.32434 +H -2.79371 -0.17372 -0.87190 +H -3.39664 -0.43471 0.77740 +H -1.07965 -0.18725 1.69281 +H -1.16526 -1.51613 0.53373 +H -0.21322 -0.03905 -1.24520 +H -0.14129 1.30361 -0.09446 +H 1.42920 -0.01820 1.35452 +H 1.36083 -1.36432 0.20523 +H 2.39817 1.45893 -0.44022 +H 2.35696 0.07954 -1.57647 diff --git a/autodE/source/autode/solvent/lib/1-bromopropane.xyz b/autodE/source/autode/solvent/lib/1-bromopropane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..f23b8a457ce136a8ec3c236facbce99b50e9c83a --- /dev/null +++ b/autodE/source/autode/solvent/lib/1-bromopropane.xyz @@ -0,0 +1,13 @@ +11 +Generated by autodE on: 2021-10-13. E = -2691.859030 Ha +C -1.21309 0.34417 -0.35501 +C -0.10258 0.02558 0.63120 +C 1.17154 -0.49720 0.00114 +Br 2.05764 0.82327 -1.13181 +H -2.09261 0.75787 0.16066 +H -0.87901 1.08026 -1.10105 +H -1.53825 -0.55818 -0.89830 +H -0.44270 -0.74963 1.34218 +H 0.13726 0.91259 1.24019 +H 1.91479 -0.77463 0.75869 +H 0.98691 -1.36419 -0.64799 diff --git a/autodE/source/autode/solvent/lib/1-butanol.xyz b/autodE/source/autode/solvent/lib/1-butanol.xyz new file mode 100644 index 0000000000000000000000000000000000000000..fd30845583da563d22b110ca057d4649b050995e --- /dev/null +++ b/autodE/source/autode/solvent/lib/1-butanol.xyz @@ -0,0 +1,17 @@ +15 +Generated by autodE on: 2021-10-13. E = -233.224214 Ha +C 1.72891 0.45044 -0.25737 +C 0.94282 -0.74131 0.27226 +C -0.54917 -0.71273 -0.05915 +C -1.24641 0.60224 0.26205 +O -2.64339 0.51899 0.16580 +H 1.59003 0.57869 -1.34392 +H 1.42105 1.39027 0.22559 +H 2.80727 0.33022 -0.07059 +H 1.37744 -1.67267 -0.12681 +H 1.07054 -0.79953 1.36768 +H -0.69440 -0.93647 -1.13356 +H -1.07184 -1.50985 0.49437 +H -1.02273 0.90039 1.30078 +H -0.84981 1.40988 -0.38417 +H -2.86039 0.19144 -0.71296 diff --git a/autodE/source/autode/solvent/lib/1-chlorohexane.xyz b/autodE/source/autode/solvent/lib/1-chlorohexane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..d4db19d292abb97c345630ac3b4293c8ef22904a --- /dev/null +++ b/autodE/source/autode/solvent/lib/1-chlorohexane.xyz @@ -0,0 +1,22 @@ +20 +Generated by autodE on: 2021-10-13. E = -695.935968 Ha +C -3.19816 -0.07317 0.27615 +C -1.78615 0.13971 0.79712 +C -0.70626 -0.23687 -0.20813 +C 0.69860 0.11624 0.25484 +C 1.78778 -0.36189 -0.69170 +C 3.17167 0.15207 -0.35751 +Cl 3.29570 1.92735 -0.58619 +H -3.95561 0.19560 1.02756 +H -3.36584 -1.12463 -0.00953 +H -3.38778 0.54083 -0.61829 +H -1.63668 -0.44451 1.72159 +H -1.65452 1.19651 1.08958 +H -0.76843 -1.31937 -0.42497 +H -0.90574 0.27156 -1.16877 +H 0.78056 1.21088 0.36465 +H 0.87677 -0.30899 1.25931 +H 1.83371 -1.46629 -0.68356 +H 1.54398 -0.06881 -1.72746 +H 3.43932 -0.05015 0.68959 +H 3.93718 -0.29617 -1.00418 diff --git a/autodE/source/autode/solvent/lib/1-chloropentane.xyz b/autodE/source/autode/solvent/lib/1-chloropentane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..a58de968d087ffe44b6a8b7493856efebd57cde7 --- /dev/null +++ b/autodE/source/autode/solvent/lib/1-chloropentane.xyz @@ -0,0 +1,19 @@ +17 +Generated by autodE on: 2021-10-13. E = -656.695375 Ha +C -2.55340 0.44881 -0.06123 +C -1.29065 -0.28858 -0.47413 +C -0.03171 0.22264 0.21067 +C 1.23553 -0.50007 -0.22472 +C 2.46044 0.04130 0.47741 +Cl 3.96946 -0.73298 -0.08268 +H -3.44142 0.03803 -0.56383 +H -2.49540 1.51928 -0.31958 +H -2.72058 0.37786 1.02579 +H -1.40326 -1.36583 -0.25722 +H -1.16032 -0.20870 -1.56838 +H -0.14780 0.12920 1.30659 +H 0.07643 1.30384 0.00400 +H 1.14935 -1.58073 -0.01620 +H 1.37189 -0.39673 -1.31491 +H 2.40577 -0.12724 1.56332 +H 2.57567 1.11990 0.29511 diff --git a/autodE/source/autode/solvent/lib/1-chloropropane.xyz b/autodE/source/autode/solvent/lib/1-chloropropane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..f85fa7bce9bec10cc3eb4b057477ea98c0cf70e9 --- /dev/null +++ b/autodE/source/autode/solvent/lib/1-chloropropane.xyz @@ -0,0 +1,13 @@ +11 +Generated by autodE on: 2021-10-13. E = -578.216047 Ha +C -1.23312 0.21185 -0.36005 +C -0.09434 0.03374 0.62809 +C 1.22443 -0.35578 -0.00847 +Cl 1.84321 0.91481 -1.11097 +H -1.46023 -0.72953 -0.88654 +H -0.97298 0.96250 -1.12018 +H -2.15056 0.54394 0.14844 +H -0.34545 -0.75337 1.36111 +H 0.05476 0.95895 1.20942 +H 2.00256 -0.51588 0.74953 +H 1.13161 -1.27134 -0.61057 diff --git a/autodE/source/autode/solvent/lib/1-decanol.xyz b/autodE/source/autode/solvent/lib/1-decanol.xyz new file mode 100644 index 0000000000000000000000000000000000000000..6249f779d442b0898c6649848fa8ec4f086470ca --- /dev/null +++ b/autodE/source/autode/solvent/lib/1-decanol.xyz @@ -0,0 +1,35 @@ +33 +Generated by autodE on: 2021-10-13. E = -468.662474 Ha +C 4.97150 0.54719 -0.03468 +C 4.24090 -0.69346 -0.53173 +C 2.76645 -0.76861 -0.13103 +C 1.95691 0.48380 -0.44754 +C 0.49437 0.42221 -0.02155 +C -0.36324 -0.59382 -0.76076 +C -1.80445 -0.67479 -0.27122 +C -2.57745 0.63368 -0.38930 +C -4.06132 0.54818 -0.04549 +C -4.38278 0.27771 1.41141 +O -4.06671 -1.05674 1.72635 +H 6.04432 0.50301 -0.27794 +H 4.57469 1.46529 -0.49302 +H 4.87963 0.66029 1.05876 +H 4.32422 -0.74443 -1.63218 +H 4.75480 -1.59340 -0.15329 +H 2.31071 -1.64177 -0.62668 +H 2.69299 -0.96825 0.95440 +H 2.01865 0.70639 -1.52934 +H 2.42061 1.34467 0.06021 +H 0.05650 1.42510 -0.15695 +H 0.44187 0.20877 1.06292 +H 0.09182 -1.59445 -0.66585 +H -0.35277 -0.35081 -1.84022 +H -2.33768 -1.45012 -0.84692 +H -1.82988 -1.02454 0.77287 +H -2.47771 1.01606 -1.42154 +H -2.11364 1.40082 0.25493 +H -4.54677 -0.23685 -0.65113 +H -4.54963 1.49677 -0.32537 +H -5.45693 0.48660 1.59017 +H -3.81381 0.98716 2.04606 +H -4.30596 -1.22138 2.64286 diff --git a/autodE/source/autode/solvent/lib/1-fluorooctane.xyz b/autodE/source/autode/solvent/lib/1-fluorooctane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..076592c54a40c63fbf8e5a0329a5475bd395869e --- /dev/null +++ b/autodE/source/autode/solvent/lib/1-fluorooctane.xyz @@ -0,0 +1,28 @@ +26 +Generated by autodE on: 2021-10-13. E = -414.170010 Ha +C -3.51120 -0.61728 -1.10967 +C -2.76226 0.69245 -0.90124 +C -1.26543 0.52500 -0.65301 +C -0.90408 -0.11072 0.68877 +C 0.59102 -0.36552 0.86099 +C 1.43066 0.89515 1.05309 +C 2.93620 0.64627 1.01318 +C 3.48017 0.43970 -0.38385 +F 3.35760 1.59700 -1.12316 +H -4.55348 -0.43957 -1.41649 +H -3.53930 -1.23267 -0.19692 +H -3.03204 -1.22201 -1.89725 +H -2.90195 1.33045 -1.78970 +H -3.21545 1.25102 -0.06240 +H -0.77439 1.50801 -0.73976 +H -0.83618 -0.09197 -1.46372 +H -1.26701 0.53488 1.50909 +H -1.44172 -1.06648 0.80438 +H 0.95310 -0.92658 -0.02031 +H 0.75548 -1.03305 1.72421 +H 1.19154 1.63970 0.27693 +H 1.15573 1.36158 2.01458 +H 3.19478 -0.23805 1.62253 +H 3.47621 1.49608 1.46159 +H 4.54763 0.16141 -0.35758 +H 2.93447 -0.35930 -0.91406 diff --git a/autodE/source/autode/solvent/lib/1-heptanol.xyz b/autodE/source/autode/solvent/lib/1-heptanol.xyz new file mode 100644 index 0000000000000000000000000000000000000000..e9476c86a4dbcf01a10b50c18dbe38d9949a0425 --- /dev/null +++ b/autodE/source/autode/solvent/lib/1-heptanol.xyz @@ -0,0 +1,26 @@ +24 +Generated by autodE on: 2021-10-13. E = -350.945618 Ha +C -3.20268 -0.07984 1.01142 +C -2.10669 -0.74048 0.19325 +C -1.39842 0.23874 -0.73512 +C -0.15588 -0.31911 -1.42632 +C 1.04593 -0.55246 -0.51317 +C 1.62352 0.71754 0.10440 +C 2.85275 0.46857 0.96546 +O 3.92069 -0.11892 0.26677 +H -3.94271 0.41887 0.36427 +H -2.77899 0.68993 1.67727 +H -3.74480 -0.80320 1.63984 +H -1.37661 -1.20856 0.87619 +H -2.52466 -1.56855 -0.40635 +H -1.13329 1.14652 -0.16467 +H -2.11551 0.57883 -1.50221 +H -0.42345 -1.26627 -1.92747 +H 0.14632 0.37067 -2.23362 +H 1.84697 -1.05683 -1.07771 +H 0.77671 -1.25229 0.29777 +H 0.86827 1.22884 0.72619 +H 1.87992 1.43684 -0.69754 +H 2.59247 -0.23447 1.77352 +H 3.16337 1.41255 1.45868 +H 4.18697 0.49337 -0.42742 diff --git a/autodE/source/autode/solvent/lib/1-hexanol.xyz b/autodE/source/autode/solvent/lib/1-hexanol.xyz new file mode 100644 index 0000000000000000000000000000000000000000..dd2ebc25173f971b7b54c7b41fcbe9615f1359b1 --- /dev/null +++ b/autodE/source/autode/solvent/lib/1-hexanol.xyz @@ -0,0 +1,23 @@ +21 +Generated by autodE on: 2021-10-13. E = -311.707106 Ha +C 3.35258 0.25133 -0.06475 +C 1.92807 0.61165 -0.45849 +C 0.86758 -0.24907 0.21053 +C -0.55753 0.10255 -0.19502 +C -1.61688 -0.75410 0.47890 +C -3.04583 -0.44075 0.05662 +O -3.46123 0.86143 0.38182 +H 3.51765 0.39084 1.01600 +H 4.08840 0.87333 -0.59448 +H 3.57776 -0.80107 -0.30357 +H 1.73608 1.67261 -0.21538 +H 1.81809 0.52605 -1.55497 +H 0.96308 -0.16026 1.30857 +H 1.05742 -1.31224 -0.02882 +H -0.65256 0.00645 -1.29363 +H -0.74126 1.16623 0.04698 +H -1.54172 -0.63264 1.57424 +H -1.41899 -1.81911 0.25884 +H -3.73649 -1.12147 0.57678 +H -3.16313 -0.64833 -1.02804 +H -2.97119 1.47687 -0.17224 diff --git a/autodE/source/autode/solvent/lib/1-hexene.xyz b/autodE/source/autode/solvent/lib/1-hexene.xyz new file mode 100644 index 0000000000000000000000000000000000000000..fa2efb92b2067bcc2201cfe3d0153c66a7a519e6 --- /dev/null +++ b/autodE/source/autode/solvent/lib/1-hexene.xyz @@ -0,0 +1,20 @@ +18 +Generated by autodE on: 2021-10-13. E = -235.436817 Ha +C 1.01637 0.95666 0.47736 +C -0.16153 1.41955 -0.37253 +C -1.36892 0.50324 -0.20519 +C -1.01638 -0.95664 -0.47790 +C 0.16134 -1.41964 0.37244 +C 1.36866 -0.50341 0.20575 +H 0.75740 1.07542 1.54619 +H 1.89361 1.59938 0.29653 +H 0.14697 1.42825 -1.43445 +H -0.43502 2.45712 -0.11874 +H -1.74818 0.59432 0.82908 +H -2.18855 0.83043 -0.86475 +H -0.75592 -1.07399 -1.54657 +H -1.89364 -1.59966 -0.29857 +H -0.14800 -1.42848 1.43412 +H 0.43470 -2.45726 0.11870 +H 1.74942 -0.59536 -0.82786 +H 2.18756 -0.83004 0.86659 diff --git a/autodE/source/autode/solvent/lib/1-hexyne.xyz b/autodE/source/autode/solvent/lib/1-hexyne.xyz new file mode 100644 index 0000000000000000000000000000000000000000..91e9754dfd671c996b20be88c8600b9c06c54fa5 --- /dev/null +++ b/autodE/source/autode/solvent/lib/1-hexyne.xyz @@ -0,0 +1,18 @@ +16 +Generated by autodE on: 2021-10-13. E = -234.141734 Ha +C 2.37386 0.91474 2.61636 +C 1.67701 0.22543 1.90466 +C 0.82557 -0.57999 1.03819 +C 0.04788 0.24684 0.01348 +C -0.82470 -0.60935 -0.89368 +C -1.62872 0.20789 -1.89098 +H 2.99262 1.53264 3.24018 +H 0.12016 -1.15619 1.66332 +H 1.44698 -1.32836 0.51534 +H 0.76036 0.83108 -0.59379 +H -0.57500 0.98541 0.54702 +H -1.50927 -1.21381 -0.27178 +H -0.18790 -1.33553 -1.43103 +H -2.25329 -0.43365 -2.53182 +H -2.29630 0.91686 -1.37493 +H -0.96928 0.79578 -2.55053 diff --git a/autodE/source/autode/solvent/lib/1-iodobutane.xyz b/autodE/source/autode/solvent/lib/1-iodobutane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..c728f5bb8fe60b5d47282a69e5f9dd70a0033877 --- /dev/null +++ b/autodE/source/autode/solvent/lib/1-iodobutane.xyz @@ -0,0 +1,16 @@ +14 +Generated by autodE on: 2021-10-13. E = -455.264080 Ha +C 1.60025 0.50505 0.02321 +C 0.59794 -0.32715 -0.76049 +C -0.57343 -0.83977 0.06990 +C -1.46907 0.22178 0.67002 +I -2.50652 1.41307 -0.81692 +H 2.04335 -0.07299 0.85096 +H 1.13767 1.40604 0.45632 +H 2.42347 0.84558 -0.62181 +H 1.10850 -1.19469 -1.20950 +H 0.20349 0.26254 -1.60588 +H -0.18538 -1.43202 0.92090 +H -1.18732 -1.53036 -0.53075 +H -0.92036 0.95460 1.27590 +H -2.27269 -0.21179 1.27823 diff --git a/autodE/source/autode/solvent/lib/1-iodohexadecane.xyz b/autodE/source/autode/solvent/lib/1-iodohexadecane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..12aa29d0978f3581ce742831b96def08c56785d2 --- /dev/null +++ b/autodE/source/autode/solvent/lib/1-iodohexadecane.xyz @@ -0,0 +1,52 @@ +50 +Generated by autodE on: 2021-10-13. E = -926.135789 Ha +C 7.33884 1.61460 0.01560 +C 6.79481 0.57350 -0.95046 +C 6.17164 -0.64298 -0.26859 +C 4.87588 -0.38779 0.49669 +C 3.70075 0.01825 -0.38440 +C 2.36013 -0.00741 0.33552 +C 1.17283 0.24750 -0.58470 +C -0.19432 0.07874 0.07161 +C -0.49369 -1.34126 0.54647 +C -1.90791 -1.52254 1.09137 +C -2.98593 -1.57618 0.01249 +C -4.41707 -1.52054 0.54295 +C -4.80730 -0.19129 1.19462 +C -4.80746 1.03914 0.28035 +C -6.12609 1.34815 -0.43710 +C -6.62110 0.22514 -1.32411 +I -8.36536 0.79783 -2.46995 +H 7.84844 2.43238 -0.51720 +H 8.06778 1.16472 0.70996 +H 6.54258 2.06928 0.62594 +H 6.06135 1.04404 -1.62926 +H 7.61357 0.22400 -1.60151 +H 6.91744 -1.08049 0.41849 +H 5.97708 -1.41670 -1.03125 +H 5.03580 0.37258 1.28120 +H 4.60358 -1.31051 1.03877 +H 3.86899 1.01909 -0.81973 +H 3.64930 -0.67210 -1.24582 +H 2.35679 0.73094 1.15805 +H 2.24957 -0.99169 0.82174 +H 1.25176 1.26516 -1.00533 +H 1.23826 -0.43672 -1.44982 +H -0.96837 0.39930 -0.64630 +H -0.28393 0.76863 0.93092 +H -0.32508 -2.04823 -0.28682 +H 0.23026 -1.62510 1.32731 +H -1.96204 -2.44786 1.69043 +H -2.11708 -0.69893 1.79635 +H -2.82684 -0.75576 -0.70813 +H -2.85471 -2.50223 -0.57336 +H -4.55492 -2.32470 1.28704 +H -5.11111 -1.76059 -0.27742 +H -5.80289 -0.29571 1.66182 +H -4.11933 -0.00408 2.03436 +H -4.55401 1.92843 0.88023 +H -3.99967 0.95547 -0.46804 +H -6.90280 1.59030 0.30686 +H -5.99459 2.25712 -1.04515 +H -6.94329 -0.65728 -0.75824 +H -5.88050 -0.08190 -2.07445 diff --git a/autodE/source/autode/solvent/lib/1-iodopentane.xyz b/autodE/source/autode/solvent/lib/1-iodopentane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..3cc87ea8db28b4dda0582f25e9f82b99e0f2bdf0 --- /dev/null +++ b/autodE/source/autode/solvent/lib/1-iodopentane.xyz @@ -0,0 +1,19 @@ +17 +Generated by autodE on: 2021-10-13. E = -494.504095 Ha +C -2.62067 0.02294 0.20253 +C -1.22621 -0.40518 0.63330 +C -0.10293 0.20463 -0.19438 +C 1.27979 -0.23658 0.27739 +C 2.39309 0.38072 -0.53620 +I 4.35685 -0.21802 0.14324 +H -2.75645 1.11160 0.30736 +H -2.81411 -0.23988 -0.85019 +H -3.39421 -0.46670 0.81168 +H -1.07320 -0.13691 1.69415 +H -1.14621 -1.50588 0.58325 +H -0.23657 -0.07311 -1.25639 +H -0.17291 1.30710 -0.15056 +H 1.40662 0.03550 1.33913 +H 1.35318 -1.33593 0.22250 +H 2.39645 1.47761 -0.47682 +H 2.35749 0.07800 -1.59177 diff --git a/autodE/source/autode/solvent/lib/1-iodopropane.xyz b/autodE/source/autode/solvent/lib/1-iodopropane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..850768f3ed19684e5ec8ee645e76a12aafb8ae4c --- /dev/null +++ b/autodE/source/autode/solvent/lib/1-iodopropane.xyz @@ -0,0 +1,13 @@ +11 +Generated by autodE on: 2021-10-13. E = -416.024269 Ha +C -1.20194 0.40041 -0.34913 +C -0.13113 -0.02789 0.63896 +C 1.14152 -0.55266 0.01099 +I 2.21794 0.94167 -1.13504 +H -2.08808 0.79128 0.17249 +H -0.82826 1.18983 -1.01909 +H -1.52697 -0.44366 -0.97847 +H -0.51993 -0.84188 1.27945 +H 0.11588 0.80174 1.32100 +H 1.86589 -0.89209 0.76124 +H 0.95488 -1.36674 -0.70230 diff --git a/autodE/source/autode/solvent/lib/1-nitropropane.xyz b/autodE/source/autode/solvent/lib/1-nitropropane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..32b63b948c973a12e14899de28b685ae504ddf27 --- /dev/null +++ b/autodE/source/autode/solvent/lib/1-nitropropane.xyz @@ -0,0 +1,15 @@ +13 +Generated by autodE on: 2021-10-13. E = -323.052173 Ha +C -1.79297 0.11241 -0.02858 +C -0.40226 -0.40838 -0.35883 +C 0.68636 0.39493 0.30810 +N 2.06481 -0.15205 0.07155 +O 2.97025 0.45235 0.59506 +O 2.17866 -1.14366 -0.60876 +H -2.55934 -0.47803 -0.54889 +H -1.92147 1.16207 -0.33860 +H -2.00356 0.05007 1.05087 +H -0.23630 -0.39472 -1.44729 +H -0.30540 -1.45985 -0.04759 +H 0.58721 0.43033 1.40330 +H 0.73399 1.43432 -0.05023 diff --git a/autodE/source/autode/solvent/lib/1-nonanol.xyz b/autodE/source/autode/solvent/lib/1-nonanol.xyz new file mode 100644 index 0000000000000000000000000000000000000000..6d46e99b70052c20b752aa14a04043eec7563066 --- /dev/null +++ b/autodE/source/autode/solvent/lib/1-nonanol.xyz @@ -0,0 +1,32 @@ +30 +Generated by autodE on: 2021-10-13. E = -429.423695 Ha +C -4.96091 0.07813 0.27063 +C -3.49796 0.44571 0.07916 +C -2.54675 -0.70987 0.37535 +C -1.10798 -0.50360 -0.09416 +C -0.42570 0.75047 0.44015 +C 0.97439 1.00627 -0.11474 +C 1.95971 -0.15030 0.01323 +C 3.37995 0.23379 -0.37520 +C 4.36463 -0.92370 -0.34605 +O 5.66996 -0.54233 -0.69411 +H -5.62560 0.93417 0.07811 +H -5.15457 -0.27329 1.29771 +H -5.26104 -0.73288 -0.41108 +H -3.33894 0.79501 -0.95716 +H -3.24998 1.30589 0.72266 +H -2.94193 -1.62151 -0.10551 +H -2.55822 -0.91935 1.46103 +H -0.51836 -1.39024 0.19315 +H -1.09185 -0.47500 -1.19975 +H -0.38182 0.69564 1.54373 +H -1.04739 1.63075 0.20660 +H 1.39783 1.88920 0.39423 +H 0.89591 1.28773 -1.18061 +H 1.62820 -0.99334 -0.61647 +H 1.95407 -0.53370 1.05037 +H 3.74136 1.02530 0.30791 +H 3.39340 0.67761 -1.38550 +H 4.33114 -1.41676 0.64827 +H 4.05872 -1.68951 -1.07744 +H 5.95973 0.11962 -0.05870 diff --git a/autodE/source/autode/solvent/lib/1-octanol.xyz b/autodE/source/autode/solvent/lib/1-octanol.xyz new file mode 100644 index 0000000000000000000000000000000000000000..d747d72ab73913fab607acbb60d33d23c06ff7dc --- /dev/null +++ b/autodE/source/autode/solvent/lib/1-octanol.xyz @@ -0,0 +1,29 @@ +27 +Generated by autodE on: 2021-10-13. E = -390.185489 Ha +C -4.24621 0.15376 -0.29541 +C -2.76242 0.41580 -0.10623 +C -1.88213 -0.44141 -1.00331 +C -0.38399 -0.20057 -0.85370 +C 0.16629 -0.56455 0.52235 +C 1.68571 -0.52359 0.63634 +C 2.29599 0.86967 0.62130 +C 3.81047 0.85944 0.74892 +O 4.45035 0.19393 -0.30931 +H -4.49694 -0.89643 -0.07323 +H -4.85971 0.78396 0.36417 +H -4.56141 0.35643 -1.33211 +H -2.54409 1.48158 -0.30203 +H -2.49490 0.23791 0.94952 +H -2.09763 -1.50768 -0.79975 +H -2.17425 -0.26572 -2.05450 +H 0.14981 -0.79475 -1.61474 +H -0.16198 0.85578 -1.08875 +H -0.26997 0.10216 1.28618 +H -0.18273 -1.58062 0.77846 +H 2.14157 -1.11395 -0.17628 +H 1.99037 -1.02582 1.57061 +H 1.87122 1.47995 1.43727 +H 2.02563 1.40076 -0.31163 +H 4.10344 0.31706 1.66248 +H 4.18519 1.89625 0.86565 +H 4.24203 0.66663 -1.12237 diff --git a/autodE/source/autode/solvent/lib/1-pentanol.xyz b/autodE/source/autode/solvent/lib/1-pentanol.xyz new file mode 100644 index 0000000000000000000000000000000000000000..238b99c4b8863cac8f42b5142487a8f2e1b16ba0 --- /dev/null +++ b/autodE/source/autode/solvent/lib/1-pentanol.xyz @@ -0,0 +1,20 @@ +18 +Generated by autodE on: 2021-10-13. E = -272.466772 Ha +C -2.60021 0.82960 -0.06195 +C -1.55380 -0.27179 -0.08233 +C -0.14368 0.22655 -0.35973 +C 0.90170 -0.87918 -0.32280 +C 2.32074 -0.38075 -0.49760 +O 2.61883 0.50812 0.54822 +H -2.66106 1.34815 -1.03286 +H -2.36281 1.59138 0.69756 +H -3.59941 0.42707 0.16694 +H -1.82728 -1.03039 -0.83690 +H -1.56073 -0.80348 0.88551 +H -0.11593 0.73093 -1.34358 +H 0.13344 0.99412 0.38091 +H 0.83883 -1.40883 0.64408 +H 0.69202 -1.63076 -1.10319 +H 3.01224 -1.24726 -0.50307 +H 2.41512 0.11715 -1.48533 +H 3.49210 0.87937 0.39573 diff --git a/autodE/source/autode/solvent/lib/1-pentene.xyz b/autodE/source/autode/solvent/lib/1-pentene.xyz new file mode 100644 index 0000000000000000000000000000000000000000..0a4f11f34d51a6427fbbcb5829418b6c6d799eb1 --- /dev/null +++ b/autodE/source/autode/solvent/lib/1-pentene.xyz @@ -0,0 +1,17 @@ +15 +Generated by autodE on: 2021-10-13. E = -196.184661 Ha +C 1.17176 0.54296 0.05602 +C -0.18483 1.25448 0.18250 +C -1.17256 0.13277 0.48541 +C -0.68510 -0.99950 -0.41291 +C 0.84480 -0.92653 -0.30720 +H 1.81732 1.02678 -0.69168 +H 1.71914 0.58989 1.01061 +H -0.46012 1.72569 -0.77602 +H -0.18386 2.05277 0.94036 +H -2.22085 0.41964 0.30661 +H -1.08934 -0.17377 1.54388 +H -1.08935 -1.98449 -0.13282 +H -1.00598 -0.80306 -1.45042 +H 1.33608 -1.25255 -1.23604 +H 1.20300 -1.60509 0.48188 diff --git a/autodE/source/autode/solvent/lib/1-propanol.xyz b/autodE/source/autode/solvent/lib/1-propanol.xyz new file mode 100644 index 0000000000000000000000000000000000000000..28e9441300e9180a6cf287f103d5a19ef03904a7 --- /dev/null +++ b/autodE/source/autode/solvent/lib/1-propanol.xyz @@ -0,0 +1,14 @@ +12 +Generated by autodE on: 2021-10-13. E = -193.986931 Ha +C -1.27466 -0.54636 0.25469 +C -0.05910 -0.30540 -0.62229 +C 0.83137 0.82312 -0.12620 +O 1.34375 0.59352 1.16268 +H -1.85242 -1.42040 -0.08211 +H -0.96810 -0.71195 1.29843 +H -1.94948 0.32513 0.24116 +H -0.37283 -0.07957 -1.65646 +H 0.55042 -1.22712 -0.68361 +H 1.64247 1.01010 -0.85831 +H 0.24741 1.75712 -0.06426 +H 1.86108 -0.21809 1.13628 diff --git a/autodE/source/autode/solvent/lib/2,2,2-trifluoroethanol.xyz b/autodE/source/autode/solvent/lib/2,2,2-trifluoroethanol.xyz new file mode 100644 index 0000000000000000000000000000000000000000..8c7c8db7c074458dd80517770335dc68974f7213 --- /dev/null +++ b/autodE/source/autode/solvent/lib/2,2,2-trifluoroethanol.xyz @@ -0,0 +1,11 @@ +9 +Generated by autodE on: 2021-10-13. E = -451.984382 Ha +F 1.50717 -0.12691 1.25039 +C 0.83571 0.18739 0.14141 +F 1.57097 -0.17756 -0.90409 +F 0.71649 1.52407 0.10156 +C -0.53258 -0.45740 0.12956 +O -1.27665 -0.06678 -0.97343 +H -1.01232 -0.22196 1.09917 +H -0.39102 -1.54656 0.07599 +H -1.41768 0.88582 -0.92085 diff --git a/autodE/source/autode/solvent/lib/2,2,4-trimethylpentane.xyz b/autodE/source/autode/solvent/lib/2,2,4-trimethylpentane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..3e63737b821a244e0729372783a08cfafdbe0dfe --- /dev/null +++ b/autodE/source/autode/solvent/lib/2,2,4-trimethylpentane.xyz @@ -0,0 +1,28 @@ +26 +Generated by autodE on: 2021-10-13. E = -315.106844 Ha +C -1.25470 -1.03264 1.11624 +C -1.20131 -0.02335 -0.03073 +C -1.41153 -0.74001 -1.36779 +C -2.33502 0.98736 0.15588 +C 0.13486 0.74483 -0.04780 +C 1.43143 -0.08041 -0.14036 +C 2.03175 -0.40237 1.22827 +C 2.46718 0.63402 -1.00390 +H -1.05979 -0.53863 2.08219 +H -2.24901 -1.50352 1.17629 +H -0.51973 -1.84111 0.98816 +H -2.38568 -1.25452 -1.38850 +H -1.39290 -0.02218 -2.20429 +H -0.63646 -1.49729 -1.55914 +H -3.31460 0.48320 0.11508 +H -2.31942 1.75967 -0.62963 +H -2.26078 1.50000 1.12866 +H 0.18457 1.39001 0.84899 +H 0.08918 1.43717 -0.90729 +H 1.19456 -1.03779 -0.63984 +H 2.34188 0.52413 1.73997 +H 2.92665 -1.03686 1.12943 +H 1.32468 -0.92587 1.88632 +H 2.74194 1.60508 -0.55918 +H 3.38950 0.04037 -1.10005 +H 2.08266 0.83051 -2.01700 diff --git a/autodE/source/autode/solvent/lib/2,4-dimethylpentane.xyz b/autodE/source/autode/solvent/lib/2,4-dimethylpentane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..33d267c0183c4de7d13e8e560553d408827283ef --- /dev/null +++ b/autodE/source/autode/solvent/lib/2,4-dimethylpentane.xyz @@ -0,0 +1,25 @@ +23 +Generated by autodE on: 2021-10-13. E = -275.865929 Ha +C 3.62662 0.09109 -0.74074 +C 2.28536 -0.59637 -0.53126 +C 1.20928 0.32343 0.03024 +C -0.15431 -0.34034 0.16418 +C -1.24168 0.59226 0.68537 +C -2.65428 0.01607 0.61650 +C -3.20483 -0.09916 -0.79740 +H 4.37223 -0.59274 -1.17336 +H 4.03468 0.47234 0.20970 +H 3.53297 0.95335 -1.42029 +H 1.92951 -1.01802 -1.48853 +H 2.41079 -1.46232 0.14206 +H 1.53211 0.71691 1.01202 +H 1.11454 1.20941 -0.62316 +H -0.44710 -0.74301 -0.82168 +H -0.07667 -1.21979 0.82839 +H -1.00449 0.87161 1.72698 +H -1.21570 1.53519 0.11047 +H -2.66854 -0.97490 1.10431 +H -3.32987 0.64878 1.21497 +H -2.56877 -0.73284 -1.43371 +H -4.21314 -0.54027 -0.80409 +H -3.26872 0.88972 -1.27986 diff --git a/autodE/source/autode/solvent/lib/2,4-dimethylpyridine.xyz b/autodE/source/autode/solvent/lib/2,4-dimethylpyridine.xyz new file mode 100644 index 0000000000000000000000000000000000000000..79f6014be18fefc53ebb0e30ee2b41b71411d9d3 --- /dev/null +++ b/autodE/source/autode/solvent/lib/2,4-dimethylpyridine.xyz @@ -0,0 +1,19 @@ +17 +Generated by autodE on: 2021-10-13. E = -326.312995 Ha +C -2.32690 -1.03039 -0.08373 +C -1.10654 -0.16302 -0.01006 +C 0.17918 -0.70800 -0.05541 +C 1.30162 0.12392 0.01964 +C 2.69330 -0.42614 -0.06385 +N 1.19197 1.45084 0.14268 +C -0.02696 1.97426 0.19342 +C -1.19885 1.22459 0.12154 +H -3.18922 -0.47683 -0.48136 +H -2.15562 -1.91052 -0.71942 +H -2.60462 -1.40255 0.91651 +H 0.30996 -1.78862 -0.16311 +H 3.33858 0.07185 0.67248 +H 3.12360 -0.21136 -1.05627 +H 2.72429 -1.51257 0.10002 +H -0.08177 3.06466 0.29978 +H -2.17211 1.71976 0.16694 diff --git a/autodE/source/autode/solvent/lib/2,6-dimethylpyridine.xyz b/autodE/source/autode/solvent/lib/2,6-dimethylpyridine.xyz new file mode 100644 index 0000000000000000000000000000000000000000..1589c47f8af251cd5137fe19095e3c23f4838712 --- /dev/null +++ b/autodE/source/autode/solvent/lib/2,6-dimethylpyridine.xyz @@ -0,0 +1,19 @@ +17 +Generated by autodE on: 2021-10-13. E = -326.314707 Ha +C -2.42252 -0.98013 -0.08358 +C -1.15721 -0.17685 -0.03437 +C -1.17964 1.22072 0.04695 +C 0.02934 1.90569 0.09861 +C 1.21649 1.18282 0.06997 +C 1.15113 -0.21362 -0.00963 +C 2.39120 -1.05614 -0.02781 +N -0.01297 -0.86207 -0.06497 +H -2.28601 -1.84612 -0.74455 +H -3.27644 -0.38100 -0.42889 +H -2.66455 -1.37621 0.91672 +H -2.13111 1.75672 0.06403 +H 0.04650 2.99750 0.15678 +H 2.18415 1.68826 0.10497 +H 2.61543 -1.43130 0.98467 +H 3.26531 -0.49322 -0.38317 +H 2.23111 -1.93502 -0.66583 diff --git a/autodE/source/autode/solvent/lib/2-bromopropane.xyz b/autodE/source/autode/solvent/lib/2-bromopropane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..73050ca6ad7db803c69ed7cad6d90c1591c7506e --- /dev/null +++ b/autodE/source/autode/solvent/lib/2-bromopropane.xyz @@ -0,0 +1,13 @@ +11 +Generated by autodE on: 2021-10-13. E = -2691.862086 Ha +C 1.18324 -0.49381 0.20657 +C 0.11699 0.49929 -0.19717 +Br 0.20276 2.05916 1.00556 +C -1.28545 -0.06685 -0.18640 +H 2.18539 -0.04529 0.15377 +H 1.01742 -0.85478 1.23304 +H 1.15570 -1.36297 -0.47030 +H 0.34976 0.92947 -1.18186 +H -1.33786 -0.93418 -0.86487 +H -1.56519 -0.40575 0.82236 +H -2.02245 0.67572 -0.52069 diff --git a/autodE/source/autode/solvent/lib/2-butanol.xyz b/autodE/source/autode/solvent/lib/2-butanol.xyz new file mode 100644 index 0000000000000000000000000000000000000000..ced3501e4f56ceda367269275e44273db17de878 --- /dev/null +++ b/autodE/source/autode/solvent/lib/2-butanol.xyz @@ -0,0 +1,17 @@ +15 +Generated by autodE on: 2021-10-13. E = -233.215047 Ha +C 1.67600 0.63996 0.44165 +C 1.11814 -0.75902 0.22701 +O 0.20956 -0.85489 -0.83809 +C -1.15034 -0.73946 -0.52257 +C -1.61939 0.68326 -0.26757 +H 0.91291 1.34410 0.80025 +H 2.08240 1.03313 -0.50229 +H 2.49130 0.62209 1.18189 +H 1.95004 -1.44171 -0.01462 +H 0.65430 -1.14077 1.15945 +H -1.69363 -1.15588 -1.38613 +H -1.39798 -1.38518 0.34573 +H -1.23075 1.08696 0.67908 +H -2.71896 0.72607 -0.22041 +H -1.28369 1.34133 -1.08338 diff --git a/autodE/source/autode/solvent/lib/2-chlorobutane.xyz b/autodE/source/autode/solvent/lib/2-chlorobutane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..70edf4e5998c2948bffe0ec7fb75ec832f4a8d01 --- /dev/null +++ b/autodE/source/autode/solvent/lib/2-chlorobutane.xyz @@ -0,0 +1,16 @@ +14 +Generated by autodE on: 2021-10-13. E = -617.459215 Ha +C -1.53986 0.19617 -0.55021 +C -0.62964 -0.64223 0.31965 +Cl -1.53394 -1.13707 1.80361 +C 0.65544 0.05249 0.74193 +C 1.60932 0.31417 -0.41362 +H -1.83185 1.11880 -0.02533 +H -1.02784 0.47665 -1.48372 +H -2.45322 -0.35473 -0.81306 +H -0.38716 -1.58973 -0.18603 +H 1.14900 -0.57602 1.49936 +H 0.39039 0.99513 1.25067 +H 1.90760 -0.62409 -0.90936 +H 2.52575 0.80385 -0.05284 +H 1.16591 0.96660 -1.18084 diff --git a/autodE/source/autode/solvent/lib/2-heptanone.xyz b/autodE/source/autode/solvent/lib/2-heptanone.xyz new file mode 100644 index 0000000000000000000000000000000000000000..7b6048c78fcbd91e2804421071508d87b0be6ba6 --- /dev/null +++ b/autodE/source/autode/solvent/lib/2-heptanone.xyz @@ -0,0 +1,24 @@ +22 +Generated by autodE on: 2021-10-13. E = -349.749886 Ha +C 2.79708 0.83431 0.71986 +C 2.19177 0.01733 -0.39664 +C 1.39657 -1.21073 0.00493 +C -0.06953 -0.87487 0.30789 +C -0.84839 -0.38702 -0.90854 +C -2.30913 -0.04886 -0.62502 +C -2.50265 1.05265 0.40485 +O 2.31139 0.34097 -1.55551 +H 3.30661 1.71198 0.30397 +H 2.01566 1.15796 1.42549 +H 3.51045 0.22354 1.29569 +H 1.85642 -1.68047 0.88881 +H 1.45020 -1.92295 -0.83321 +H -0.55832 -1.77303 0.72411 +H -0.11186 -0.11933 1.11059 +H -0.79269 -1.15748 -1.69637 +H -0.34693 0.49883 -1.33500 +H -2.84410 -0.95667 -0.29231 +H -2.78862 0.25463 -1.57093 +H -2.18361 0.73547 1.41024 +H -1.92175 1.95051 0.13558 +H -3.55857 1.35324 0.48134 diff --git a/autodE/source/autode/solvent/lib/2-hexanone.xyz b/autodE/source/autode/solvent/lib/2-hexanone.xyz new file mode 100644 index 0000000000000000000000000000000000000000..7b8b2a7f8c320168ed81f5e34fc662d325481d01 --- /dev/null +++ b/autodE/source/autode/solvent/lib/2-hexanone.xyz @@ -0,0 +1,21 @@ +19 +Generated by autodE on: 2021-10-13. E = -310.513008 Ha +C -3.27131 -0.49464 0.01600 +C -1.99577 0.07808 0.58535 +C -0.72690 -0.14751 -0.21131 +C 0.53540 0.17662 0.56523 +C 1.80571 0.08880 -0.26663 +C 3.06841 0.37288 0.53168 +O -1.98336 0.68565 1.62867 +H -3.27881 -1.58580 0.18002 +H -3.34386 -0.32785 -1.06986 +H -4.13540 -0.05566 0.52938 +H -0.71906 -1.18242 -0.59769 +H -0.80388 0.48577 -1.11536 +H 0.43202 1.18419 0.99928 +H 0.60695 -0.50146 1.43244 +H 1.73295 0.79382 -1.11397 +H 1.87758 -0.91415 -0.72384 +H 3.97019 0.28464 -0.09311 +H 3.05402 1.38844 0.95996 +H 3.17490 -0.32949 1.37298 diff --git a/autodE/source/autode/solvent/lib/2-methoxyethanol.xyz b/autodE/source/autode/solvent/lib/2-methoxyethanol.xyz new file mode 100644 index 0000000000000000000000000000000000000000..cb92493bac89d5dce0887ffe96fa73fc1ccb200f --- /dev/null +++ b/autodE/source/autode/solvent/lib/2-methoxyethanol.xyz @@ -0,0 +1,15 @@ +13 +Generated by autodE on: 2021-10-13. E = -269.051775 Ha +C 1.67154 0.64410 -0.12614 +O 0.95490 -0.32407 -0.83869 +C -0.02780 -0.98720 -0.10179 +C -1.35143 -0.25667 -0.04609 +O -1.23862 0.91118 0.72745 +H 2.46663 1.02012 -0.78559 +H 1.03186 1.48009 0.20052 +H 2.14745 0.20600 0.77469 +H -0.20000 -1.95854 -0.59590 +H 0.32251 -1.19835 0.92952 +H -1.65304 -0.02768 -1.08718 +H -2.10330 -0.95778 0.37493 +H -2.02082 1.44867 0.57418 diff --git a/autodE/source/autode/solvent/lib/2-methyl-1-propanol.xyz b/autodE/source/autode/solvent/lib/2-methyl-1-propanol.xyz new file mode 100644 index 0000000000000000000000000000000000000000..fd30845583da563d22b110ca057d4649b050995e --- /dev/null +++ b/autodE/source/autode/solvent/lib/2-methyl-1-propanol.xyz @@ -0,0 +1,17 @@ +15 +Generated by autodE on: 2021-10-13. E = -233.224214 Ha +C 1.72891 0.45044 -0.25737 +C 0.94282 -0.74131 0.27226 +C -0.54917 -0.71273 -0.05915 +C -1.24641 0.60224 0.26205 +O -2.64339 0.51899 0.16580 +H 1.59003 0.57869 -1.34392 +H 1.42105 1.39027 0.22559 +H 2.80727 0.33022 -0.07059 +H 1.37744 -1.67267 -0.12681 +H 1.07054 -0.79953 1.36768 +H -0.69440 -0.93647 -1.13356 +H -1.07184 -1.50985 0.49437 +H -1.02273 0.90039 1.30078 +H -0.84981 1.40988 -0.38417 +H -2.86039 0.19144 -0.71296 diff --git a/autodE/source/autode/solvent/lib/2-methyl-2-propanol.xyz b/autodE/source/autode/solvent/lib/2-methyl-2-propanol.xyz new file mode 100644 index 0000000000000000000000000000000000000000..ced3501e4f56ceda367269275e44273db17de878 --- /dev/null +++ b/autodE/source/autode/solvent/lib/2-methyl-2-propanol.xyz @@ -0,0 +1,17 @@ +15 +Generated by autodE on: 2021-10-13. E = -233.215047 Ha +C 1.67600 0.63996 0.44165 +C 1.11814 -0.75902 0.22701 +O 0.20956 -0.85489 -0.83809 +C -1.15034 -0.73946 -0.52257 +C -1.61939 0.68326 -0.26757 +H 0.91291 1.34410 0.80025 +H 2.08240 1.03313 -0.50229 +H 2.49130 0.62209 1.18189 +H 1.95004 -1.44171 -0.01462 +H 0.65430 -1.14077 1.15945 +H -1.69363 -1.15588 -1.38613 +H -1.39798 -1.38518 0.34573 +H -1.23075 1.08696 0.67908 +H -2.71896 0.72607 -0.22041 +H -1.28369 1.34133 -1.08338 diff --git a/autodE/source/autode/solvent/lib/2-methylpentane.xyz b/autodE/source/autode/solvent/lib/2-methylpentane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..b2b9d6a73b29dabbd14d83e6d1203ad53f7b41f8 --- /dev/null +++ b/autodE/source/autode/solvent/lib/2-methylpentane.xyz @@ -0,0 +1,22 @@ +20 +Generated by autodE on: 2021-10-13. E = -236.626249 Ha +C -2.99897 0.52805 0.19776 +C -1.58973 0.20378 0.67642 +C -0.75484 -0.52598 -0.37087 +C 0.68384 -0.82288 0.04507 +C 1.59875 0.39758 0.08056 +C 3.04466 0.05140 0.39973 +H -2.98077 1.20051 -0.67518 +H -3.53611 -0.38418 -0.10959 +H -3.59660 1.01915 0.98105 +H -1.63808 -0.41731 1.58823 +H -1.08214 1.13519 0.98150 +H -1.26199 -1.47248 -0.62869 +H -0.74398 0.06557 -1.30414 +H 1.11614 -1.56125 -0.65245 +H 0.68842 -1.31539 1.03504 +H 1.22317 1.12906 0.81638 +H 1.55092 0.90680 -0.89827 +H 3.45115 -0.67116 -0.32555 +H 3.69044 0.94235 0.37986 +H 3.13592 -0.40899 1.39752 diff --git a/autodE/source/autode/solvent/lib/2-methylpyridine.xyz b/autodE/source/autode/solvent/lib/2-methylpyridine.xyz new file mode 100644 index 0000000000000000000000000000000000000000..a998f6e11beadc562962c3b0bdbdd405c939b538 --- /dev/null +++ b/autodE/source/autode/solvent/lib/2-methylpyridine.xyz @@ -0,0 +1,16 @@ +14 +Generated by autodE on: 2021-10-13. E = -287.067276 Ha +C -2.14587 -0.54383 -0.21412 +C -0.68492 -0.24530 -0.05677 +N 0.12168 -1.29180 0.14615 +C 1.42544 -1.07662 0.27908 +C 2.01192 0.18788 0.22126 +C 1.17664 1.28221 0.02346 +C -0.19035 1.06426 -0.11772 +H -2.57001 -0.89111 0.74143 +H -2.28735 -1.36001 -0.93659 +H -2.71358 0.33456 -0.55040 +H 2.05009 -1.96299 0.44298 +H 3.09164 0.30814 0.33367 +H 1.58755 2.29416 -0.02833 +H -0.87286 1.90045 -0.28400 diff --git a/autodE/source/autode/solvent/lib/2-nitropropane.xyz b/autodE/source/autode/solvent/lib/2-nitropropane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..062df88ce3e6e0cf62f384b1b5c61b52df6e03d0 --- /dev/null +++ b/autodE/source/autode/solvent/lib/2-nitropropane.xyz @@ -0,0 +1,15 @@ +13 +Generated by autodE on: 2021-10-13. E = -323.057173 Ha +C -1.12679 -0.53885 -0.66240 +C -0.15843 -0.02988 0.38492 +N 0.97764 -1.01664 0.52922 +O 1.57324 -1.31486 -0.48011 +O 1.22919 -1.41366 1.64229 +C 0.45026 1.32246 0.05837 +H -1.93742 0.18963 -0.80523 +H -0.60743 -0.68051 -1.62024 +H -1.57738 -1.49497 -0.35858 +H -0.61767 -0.01807 1.38194 +H 0.94280 1.29514 -0.92463 +H -0.33987 2.08647 0.03915 +H 1.19198 1.61376 0.81530 diff --git a/autodE/source/autode/solvent/lib/2-octanone.xyz b/autodE/source/autode/solvent/lib/2-octanone.xyz new file mode 100644 index 0000000000000000000000000000000000000000..4774a14401d0cdfd87267fdff66a9667dffecce8 --- /dev/null +++ b/autodE/source/autode/solvent/lib/2-octanone.xyz @@ -0,0 +1,27 @@ +25 +Generated by autodE on: 2021-10-13. E = -388.990271 Ha +C -3.77130 -0.91195 0.73965 +C -3.02772 0.36109 0.41397 +C -1.60472 0.18674 -0.07258 +C -0.76321 1.45015 0.01106 +C 0.65540 1.30665 -0.54185 +C 1.35541 0.00798 -0.15824 +C 2.83158 -0.04868 -0.52450 +C 3.43337 -1.41778 -0.24860 +O -3.53096 1.45043 0.55169 +H -3.63430 -1.67377 -0.04297 +H -3.35397 -1.33440 1.66941 +H -4.83738 -0.70133 0.89073 +H -1.66811 -0.19090 -1.11192 +H -1.14967 -0.64011 0.49657 +H -0.71890 1.76197 1.06833 +H -1.29589 2.26168 -0.50639 +H 0.63832 1.38633 -1.64409 +H 1.25812 2.16364 -0.19417 +H 1.24685 -0.17037 0.92841 +H 0.84555 -0.83991 -0.64747 +H 3.37997 0.72805 0.03584 +H 2.95821 0.21107 -1.59102 +H 4.49929 -1.46600 -0.51890 +H 3.34399 -1.68070 0.81849 +H 2.90985 -2.19969 -0.82043 diff --git a/autodE/source/autode/solvent/lib/2-pentanone.xyz b/autodE/source/autode/solvent/lib/2-pentanone.xyz new file mode 100644 index 0000000000000000000000000000000000000000..20492884bea522a76b3d9887f813b12b95e93127 --- /dev/null +++ b/autodE/source/autode/solvent/lib/2-pentanone.xyz @@ -0,0 +1,18 @@ +16 +Generated by autodE on: 2021-10-13. E = -271.272749 Ha +C 2.53020 -0.34330 0.82425 +C 1.42242 -0.02701 -0.15066 +C 0.00606 -0.12904 0.37606 +C -1.05613 0.26061 -0.63604 +C -2.46071 0.26219 -0.05550 +O 1.65601 0.29693 -1.29074 +H 3.49993 -0.18134 0.33657 +H 2.45809 -1.38863 1.16568 +H 2.45171 0.28788 1.72216 +H -0.14612 -1.16099 0.74524 +H -0.06025 0.49874 1.28295 +H -0.80591 1.25405 -1.04175 +H -0.99300 -0.42210 -1.49836 +H -2.54838 0.96250 0.79073 +H -2.74920 -0.73383 0.31895 +H -3.20491 0.56324 -0.80756 diff --git a/autodE/source/autode/solvent/lib/2-propanol.xyz b/autodE/source/autode/solvent/lib/2-propanol.xyz new file mode 100644 index 0000000000000000000000000000000000000000..084d04e298aa6959e2b10724c4bbd8d92c935866 --- /dev/null +++ b/autodE/source/autode/solvent/lib/2-propanol.xyz @@ -0,0 +1,14 @@ +12 +Generated by autodE on: 2021-10-13. E = -193.991570 Ha +C 1.29398 0.49368 0.10778 +C 0.11115 -0.44388 -0.01732 +O 0.08840 -0.90162 -1.35182 +C -1.20693 0.22062 0.34882 +H 1.43262 0.82184 1.14877 +H 2.21163 -0.01031 -0.22877 +H 1.14446 1.38509 -0.52047 +H 0.27858 -1.29897 0.67186 +H -0.70302 -1.43475 -1.47260 +H -1.38855 1.08847 -0.30319 +H -2.04573 -0.48435 0.22215 +H -1.21648 0.56408 1.39499 diff --git a/autodE/source/autode/solvent/lib/2-propen-1-ol.xyz b/autodE/source/autode/solvent/lib/2-propen-1-ol.xyz new file mode 100644 index 0000000000000000000000000000000000000000..4a04c7ebfe3adb33192b8cb95a2474003d630001 --- /dev/null +++ b/autodE/source/autode/solvent/lib/2-propen-1-ol.xyz @@ -0,0 +1,12 @@ +10 +Generated by autodE on: 2021-10-13. E = -192.791474 Ha +C -1.28021 0.14373 -0.13953 +C 0.00718 -0.00688 0.63727 +C 1.27670 -0.14282 -0.17071 +O 0.02001 -0.01512 1.84458 +H -2.12298 0.19568 0.56114 +H -1.25253 1.05928 -0.75270 +H -1.42098 -0.69911 -0.83446 +H 2.13448 -0.22512 0.50848 +H 1.40916 0.72463 -0.83695 +H 1.22916 -1.03418 -0.81712 diff --git a/autodE/source/autode/solvent/lib/3-methylpyridine.xyz b/autodE/source/autode/solvent/lib/3-methylpyridine.xyz new file mode 100644 index 0000000000000000000000000000000000000000..6da0a2674580e1fb8eae86b59c0faae484c8a054 --- /dev/null +++ b/autodE/source/autode/solvent/lib/3-methylpyridine.xyz @@ -0,0 +1,16 @@ +14 +Generated by autodE on: 2021-10-13. E = -287.064528 Ha +C 2.06698 0.21534 0.16007 +C 0.59471 -0.00027 0.00190 +C -0.34767 0.93223 0.44628 +C -1.70275 0.66226 0.28768 +C -2.07619 -0.54119 -0.31187 +N -1.19639 -1.44098 -0.74154 +C 0.09416 -1.16823 -0.58746 +H 2.38893 0.00209 1.19316 +H 2.35124 1.25375 -0.06749 +H 2.64143 -0.44572 -0.50236 +H -0.01805 1.86548 0.91321 +H -2.45955 1.37466 0.62244 +H -3.13541 -0.78549 -0.45680 +H 0.79866 -1.92392 -0.95721 diff --git a/autodE/source/autode/solvent/lib/3-pentanone.xyz b/autodE/source/autode/solvent/lib/3-pentanone.xyz new file mode 100644 index 0000000000000000000000000000000000000000..20492884bea522a76b3d9887f813b12b95e93127 --- /dev/null +++ b/autodE/source/autode/solvent/lib/3-pentanone.xyz @@ -0,0 +1,18 @@ +16 +Generated by autodE on: 2021-10-13. E = -271.272749 Ha +C 2.53020 -0.34330 0.82425 +C 1.42242 -0.02701 -0.15066 +C 0.00606 -0.12904 0.37606 +C -1.05613 0.26061 -0.63604 +C -2.46071 0.26219 -0.05550 +O 1.65601 0.29693 -1.29074 +H 3.49993 -0.18134 0.33657 +H 2.45809 -1.38863 1.16568 +H 2.45171 0.28788 1.72216 +H -0.14612 -1.16099 0.74524 +H -0.06025 0.49874 1.28295 +H -0.80591 1.25405 -1.04175 +H -0.99300 -0.42210 -1.49836 +H -2.54838 0.96250 0.79073 +H -2.74920 -0.73383 0.31895 +H -3.20491 0.56324 -0.80756 diff --git a/autodE/source/autode/solvent/lib/4-heptanone.xyz b/autodE/source/autode/solvent/lib/4-heptanone.xyz new file mode 100644 index 0000000000000000000000000000000000000000..7b6048c78fcbd91e2804421071508d87b0be6ba6 --- /dev/null +++ b/autodE/source/autode/solvent/lib/4-heptanone.xyz @@ -0,0 +1,24 @@ +22 +Generated by autodE on: 2021-10-13. E = -349.749886 Ha +C 2.79708 0.83431 0.71986 +C 2.19177 0.01733 -0.39664 +C 1.39657 -1.21073 0.00493 +C -0.06953 -0.87487 0.30789 +C -0.84839 -0.38702 -0.90854 +C -2.30913 -0.04886 -0.62502 +C -2.50265 1.05265 0.40485 +O 2.31139 0.34097 -1.55551 +H 3.30661 1.71198 0.30397 +H 2.01566 1.15796 1.42549 +H 3.51045 0.22354 1.29569 +H 1.85642 -1.68047 0.88881 +H 1.45020 -1.92295 -0.83321 +H -0.55832 -1.77303 0.72411 +H -0.11186 -0.11933 1.11059 +H -0.79269 -1.15748 -1.69637 +H -0.34693 0.49883 -1.33500 +H -2.84410 -0.95667 -0.29231 +H -2.78862 0.25463 -1.57093 +H -2.18361 0.73547 1.41024 +H -1.92175 1.95051 0.13558 +H -3.55857 1.35324 0.48134 diff --git a/autodE/source/autode/solvent/lib/4-methyl-2-pentanone.xyz b/autodE/source/autode/solvent/lib/4-methyl-2-pentanone.xyz new file mode 100644 index 0000000000000000000000000000000000000000..7b8b2a7f8c320168ed81f5e34fc662d325481d01 --- /dev/null +++ b/autodE/source/autode/solvent/lib/4-methyl-2-pentanone.xyz @@ -0,0 +1,21 @@ +19 +Generated by autodE on: 2021-10-13. E = -310.513008 Ha +C -3.27131 -0.49464 0.01600 +C -1.99577 0.07808 0.58535 +C -0.72690 -0.14751 -0.21131 +C 0.53540 0.17662 0.56523 +C 1.80571 0.08880 -0.26663 +C 3.06841 0.37288 0.53168 +O -1.98336 0.68565 1.62867 +H -3.27881 -1.58580 0.18002 +H -3.34386 -0.32785 -1.06986 +H -4.13540 -0.05566 0.52938 +H -0.71906 -1.18242 -0.59769 +H -0.80388 0.48577 -1.11536 +H 0.43202 1.18419 0.99928 +H 0.60695 -0.50146 1.43244 +H 1.73295 0.79382 -1.11397 +H 1.87758 -0.91415 -0.72384 +H 3.97019 0.28464 -0.09311 +H 3.05402 1.38844 0.95996 +H 3.17490 -0.32949 1.37298 diff --git a/autodE/source/autode/solvent/lib/4=methylpyridine.xyz b/autodE/source/autode/solvent/lib/4=methylpyridine.xyz new file mode 100644 index 0000000000000000000000000000000000000000..6e4269728e4209e99f88210da2034ce4a87dfdee --- /dev/null +++ b/autodE/source/autode/solvent/lib/4=methylpyridine.xyz @@ -0,0 +1,16 @@ +14 +Generated by autodE on: 2021-10-13. E = -287.065437 Ha +C 1.83726 0.06438 0.82560 +C 0.46201 0.01411 0.23250 +C -0.26037 -1.17826 0.13192 +C -1.51741 -1.16975 -0.47201 +N -2.09364 -0.07705 -0.96488 +C -1.41463 1.06327 -0.85951 +C -0.15177 1.16197 -0.28008 +H 2.59157 0.13873 0.02470 +H 2.06193 -0.83688 1.41328 +H 1.96900 0.94328 1.47392 +H 0.15324 -2.11082 0.52479 +H -2.08758 -2.10210 -0.56145 +H -1.90317 1.95854 -1.26096 +H 0.35355 2.13036 -0.22784 diff --git a/autodE/source/autode/solvent/lib/5-nonanone.xyz b/autodE/source/autode/solvent/lib/5-nonanone.xyz new file mode 100644 index 0000000000000000000000000000000000000000..0553567754c74d84ebc9337f2dc1eddef1b3e542 --- /dev/null +++ b/autodE/source/autode/solvent/lib/5-nonanone.xyz @@ -0,0 +1,30 @@ +28 +Generated by autodE on: 2021-10-13. E = -428.235127 Ha +C 4.45951 -0.62228 0.79932 +C 3.18268 -0.06176 0.19157 +C 2.23587 0.53303 1.22877 +C 0.98400 1.14276 0.62145 +C 0.00717 0.13133 0.05048 +C -0.97300 0.67612 -0.96547 +C -2.16948 -0.20478 -1.27657 +C -3.18130 -0.28546 -0.13773 +C -4.51673 -0.87899 -0.55828 +O 0.01745 -1.03089 0.38557 +H 5.11816 -1.05920 0.03319 +H 4.23680 -1.41057 1.53508 +H 5.03051 0.16338 1.32183 +H 3.43587 0.71421 -0.55279 +H 2.65319 -0.85628 -0.36083 +H 1.93482 -0.25380 1.93889 +H 2.76777 1.30421 1.81209 +H 0.40878 1.70290 1.38314 +H 1.23058 1.88724 -0.15472 +H -1.29578 1.68069 -0.63837 +H -0.37214 0.85489 -1.87726 +H -1.81675 -1.21621 -1.53733 +H -2.66809 0.19855 -2.17441 +H -3.35340 0.72989 0.26399 +H -2.74526 -0.87027 0.68855 +H -5.22742 -0.90679 0.28189 +H -4.40586 -1.90743 -0.93918 +H -4.97786 -0.28362 -1.36276 diff --git a/autodE/source/autode/solvent/lib/acetic acid.xyz b/autodE/source/autode/solvent/lib/acetic acid.xyz new file mode 100644 index 0000000000000000000000000000000000000000..49ff2fc80ec6d9546359394ea8c50bb19f383553 --- /dev/null +++ b/autodE/source/autode/solvent/lib/acetic acid.xyz @@ -0,0 +1,10 @@ +8 +Generated by autodE on: 2021-10-13. E = -228.669683 Ha +C -0.95616 -0.05298 0.06426 +C 0.49030 0.31501 -0.05783 +O 1.28035 -0.77010 -0.01023 +O 0.92403 1.42888 -0.18559 +H -1.56762 0.85486 0.03974 +H -1.12356 -0.60496 1.00083 +H -1.23800 -0.72518 -0.75880 +H 2.19056 -0.44553 -0.09238 diff --git a/autodE/source/autode/solvent/lib/acetone.xyz b/autodE/source/autode/solvent/lib/acetone.xyz new file mode 100644 index 0000000000000000000000000000000000000000..4a04c7ebfe3adb33192b8cb95a2474003d630001 --- /dev/null +++ b/autodE/source/autode/solvent/lib/acetone.xyz @@ -0,0 +1,12 @@ +10 +Generated by autodE on: 2021-10-13. E = -192.791474 Ha +C -1.28021 0.14373 -0.13953 +C 0.00718 -0.00688 0.63727 +C 1.27670 -0.14282 -0.17071 +O 0.02001 -0.01512 1.84458 +H -2.12298 0.19568 0.56114 +H -1.25253 1.05928 -0.75270 +H -1.42098 -0.69911 -0.83446 +H 2.13448 -0.22512 0.50848 +H 1.40916 0.72463 -0.83695 +H 1.22916 -1.03418 -0.81712 diff --git a/autodE/source/autode/solvent/lib/acetonitrile.xyz b/autodE/source/autode/solvent/lib/acetonitrile.xyz new file mode 100644 index 0000000000000000000000000000000000000000..cd950e604d2abfc206bc3b38ab67737e7d145813 --- /dev/null +++ b/autodE/source/autode/solvent/lib/acetonitrile.xyz @@ -0,0 +1,8 @@ +6 +Generated by autodE on: 2021-10-13. E = -132.494513 Ha +C -0.48550 -0.00802 -0.00336 +C 0.96594 0.01553 0.00445 +N 2.12247 0.03314 0.01399 +H -0.85388 -0.70306 -0.76993 +H -0.86697 -0.33195 0.97583 +H -0.88207 0.99437 -0.22099 diff --git a/autodE/source/autode/solvent/lib/acetophenone.xyz b/autodE/source/autode/solvent/lib/acetophenone.xyz new file mode 100644 index 0000000000000000000000000000000000000000..11555f7597642759da5f6945610980c7916d1d57 --- /dev/null +++ b/autodE/source/autode/solvent/lib/acetophenone.xyz @@ -0,0 +1,19 @@ +17 +Generated by autodE on: 2021-10-13. E = -384.184197 Ha +C -2.59691 -0.56381 -0.54413 +C -1.69967 0.58541 -0.15924 +C -0.24186 0.30948 0.03256 +C 0.29910 -0.98039 -0.02138 +C 1.66515 -1.17852 0.16124 +C 2.50287 -0.08821 0.38636 +C 1.97140 1.20192 0.43954 +C 0.60700 1.39799 0.26857 +O -2.13929 1.70571 -0.01854 +H -3.60972 -0.17945 -0.71252 +H -2.62345 -1.32630 0.24988 +H -2.23077 -1.05832 -1.45738 +H -0.34504 -1.84108 -0.20997 +H 2.08167 -2.18786 0.12218 +H 3.57483 -0.24608 0.52847 +H 2.62598 2.05687 0.62186 +H 0.15851 2.39286 0.31251 diff --git a/autodE/source/autode/solvent/lib/aniline.xyz b/autodE/source/autode/solvent/lib/aniline.xyz new file mode 100644 index 0000000000000000000000000000000000000000..6becdd47d00e0cc0fa47bb97d30fbfa239c65e44 --- /dev/null +++ b/autodE/source/autode/solvent/lib/aniline.xyz @@ -0,0 +1,16 @@ +14 +Generated by autodE on: 2021-10-13. E = -287.073642 Ha +N 2.29174 -0.28703 0.05677 +C 0.92149 -0.11383 0.02168 +C 0.05635 -1.21907 0.08639 +C -1.32217 -1.03874 0.10922 +C -1.87674 0.23992 0.06472 +C -1.02354 1.34038 -0.00504 +C 0.35651 1.17209 -0.02825 +H 2.84483 0.46988 -0.32309 +H 2.62964 -1.19591 -0.23230 +H 0.47972 -2.22680 0.12838 +H -1.97149 -1.91578 0.16514 +H -2.95948 0.38023 0.08554 +H -1.44046 2.35023 -0.04578 +H 1.01350 2.04444 -0.08338 diff --git a/autodE/source/autode/solvent/lib/anisole.xyz b/autodE/source/autode/solvent/lib/anisole.xyz new file mode 100644 index 0000000000000000000000000000000000000000..3de4ba6ccb3792089c3391435b6c537acdb08c0d --- /dev/null +++ b/autodE/source/autode/solvent/lib/anisole.xyz @@ -0,0 +1,18 @@ +16 +Generated by autodE on: 2021-10-13. E = -346.130064 Ha +C -2.64405 -0.16040 -0.41907 +O -1.53098 -1.00243 -0.32917 +C -0.32072 -0.45863 -0.08110 +C 0.76372 -1.34675 -0.05729 +C 2.04940 -0.87332 0.16691 +C 2.27967 0.48869 0.37743 +C 1.20026 1.36495 0.36626 +C -0.09916 0.90444 0.14129 +H -3.50354 -0.80189 -0.65235 +H -2.84072 0.37096 0.53008 +H -2.52975 0.58978 -1.22191 +H 0.56463 -2.40618 -0.23212 +H 2.88538 -1.57683 0.17610 +H 3.29010 0.86202 0.55678 +H 1.36237 2.43173 0.54139 +H -0.92682 1.61386 0.13696 diff --git a/autodE/source/autode/solvent/lib/argon.xyz b/autodE/source/autode/solvent/lib/argon.xyz new file mode 100644 index 0000000000000000000000000000000000000000..6af6a632890ae3b40f24a218231b25d75ed5559c --- /dev/null +++ b/autodE/source/autode/solvent/lib/argon.xyz @@ -0,0 +1,3 @@ +1 +Generated by autodE on: 2021-10-13. E = -527.189109 Ha +Ar 0.00100 0.00100 0.00100 diff --git a/autodE/source/autode/solvent/lib/benzaldehyde.xyz b/autodE/source/autode/solvent/lib/benzaldehyde.xyz new file mode 100644 index 0000000000000000000000000000000000000000..c3a05430bc2899b52f4449bf9e36efdd2fa8698d --- /dev/null +++ b/autodE/source/autode/solvent/lib/benzaldehyde.xyz @@ -0,0 +1,16 @@ +14 +Generated by autodE on: 2021-10-13. E = -344.934801 Ha +O 2.96875 -1.16934 -0.04657 +C 2.32957 -0.18866 0.24033 +C 0.86362 -0.06963 0.08854 +C 0.13256 -1.13116 -0.46139 +C -1.24482 -1.01997 -0.60484 +C -1.89482 0.14920 -0.19764 +C -1.17236 1.20630 0.35507 +C 0.20953 1.09772 0.49426 +H 2.82860 0.72465 0.65732 +H 0.67448 -2.02829 -0.77009 +H -1.81965 -1.84297 -1.03612 +H -2.97772 0.23842 -0.31627 +H -1.68928 2.11345 0.67622 +H 0.79162 1.92016 0.92107 diff --git a/autodE/source/autode/solvent/lib/benzene.xyz b/autodE/source/autode/solvent/lib/benzene.xyz new file mode 100644 index 0000000000000000000000000000000000000000..9726f20253b95d89fb40360282eb0191e9bd4021 --- /dev/null +++ b/autodE/source/autode/solvent/lib/benzene.xyz @@ -0,0 +1,14 @@ +12 +Generated by autodE on: 2021-10-13. E = -231.812028 Ha +C -0.30025 -1.36189 0.01422 +C -1.32908 -0.42173 0.04266 +C -1.02839 0.94002 0.02868 +C 0.30030 1.36191 -0.01422 +C 1.32907 0.42175 -0.04266 +C 1.02834 -0.93999 -0.02868 +H -0.53733 -2.42897 0.02537 +H -2.36972 -0.75438 0.07807 +H -1.83492 1.67665 0.05301 +H 0.53715 2.42912 -0.02536 +H 2.36984 0.75399 -0.07808 +H 1.83501 -1.67647 -0.05301 diff --git a/autodE/source/autode/solvent/lib/benzonitrile.xyz b/autodE/source/autode/solvent/lib/benzonitrile.xyz new file mode 100644 index 0000000000000000000000000000000000000000..eaff3e447ee6a38453a27fd2ecdadf20fe10327a --- /dev/null +++ b/autodE/source/autode/solvent/lib/benzonitrile.xyz @@ -0,0 +1,15 @@ +13 +Generated by autodE on: 2021-10-13. E = -323.882332 Ha +N 3.64255 0.16142 -0.21966 +C 2.48578 0.11366 -0.15537 +C 1.05658 0.05299 -0.07169 +C 0.35383 -0.92141 -0.79709 +C -1.03234 -0.98477 -0.70124 +C -1.71906 -0.08383 0.11331 +C -1.02242 0.89063 0.82900 +C 0.36355 0.96433 0.74034 +H 0.90364 -1.62305 -1.42691 +H -1.58129 -1.74316 -1.26344 +H -2.80727 -0.14422 0.19491 +H -1.56429 1.59710 1.46149 +H 0.92064 1.72032 1.29645 diff --git a/autodE/source/autode/solvent/lib/benzyl alcohol.xyz b/autodE/source/autode/solvent/lib/benzyl alcohol.xyz new file mode 100644 index 0000000000000000000000000000000000000000..e090c856ed12a5d9cc3b0835d92335d8c4ae3f7c --- /dev/null +++ b/autodE/source/autode/solvent/lib/benzyl alcohol.xyz @@ -0,0 +1,18 @@ +16 +Generated by autodE on: 2021-10-13. E = -346.149851 Ha +C 2.09001 -0.34873 0.06201 +C 0.59644 -0.22293 0.00943 +C -0.23402 -1.34151 -0.09756 +C -1.62028 -1.22091 -0.15002 +C -2.19558 0.04808 -0.10194 +C -1.39266 1.17786 0.00851 +C -0.00402 1.04774 0.07639 +O 0.72822 2.17775 0.20586 +H 2.58667 0.26188 -0.71215 +H 2.50066 -0.04031 1.04052 +H 2.40282 -1.38832 -0.10513 +H 0.22866 -2.33146 -0.14453 +H -2.24558 -2.11141 -0.23654 +H -3.28114 0.16470 -0.15459 +H -1.82187 2.18077 0.04935 +H 1.66185 1.94700 0.25049 diff --git a/autodE/source/autode/solvent/lib/benzyl chloride.xyz b/autodE/source/autode/solvent/lib/benzyl chloride.xyz new file mode 100644 index 0000000000000000000000000000000000000000..a1c918f6fae8db5cf803acde9bc56d2a91136e2f --- /dev/null +++ b/autodE/source/autode/solvent/lib/benzyl chloride.xyz @@ -0,0 +1,17 @@ +15 +Generated by autodE on: 2021-10-13. E = -730.361577 Ha +Cl 2.98938 0.39824 -1.24716 +C 2.17261 -0.17673 0.25095 +C 0.68663 -0.07754 0.12909 +C -0.08369 -1.20307 -0.17613 +C -1.46624 -1.09742 -0.31578 +C -2.09090 0.13812 -0.15764 +C -1.32862 1.26835 0.13778 +C 0.05080 1.16013 0.27937 +H 2.49967 -1.21189 0.41251 +H 2.55937 0.44648 1.06735 +H 0.40802 -2.16934 -0.31811 +H -2.05690 -1.98308 -0.56033 +H -3.17405 0.22427 -0.27246 +H -1.81612 2.23798 0.26448 +H 0.65004 2.04571 0.50607 diff --git a/autodE/source/autode/solvent/lib/bromobenzene.xyz b/autodE/source/autode/solvent/lib/bromobenzene.xyz new file mode 100644 index 0000000000000000000000000000000000000000..df92cd907b8153d2e8b93bf4c41fa154342b546e --- /dev/null +++ b/autodE/source/autode/solvent/lib/bromobenzene.xyz @@ -0,0 +1,14 @@ +12 +Generated by autodE on: 2021-10-13. E = -2804.763513 Ha +Br 3.15097 -0.58604 0.00182 +C 1.29423 -0.23853 0.00156 +C 0.84010 1.07895 -0.03413 +C -0.53193 1.32405 -0.03523 +C -1.43804 0.26610 -0.00163 +C -0.96818 -1.04588 0.03418 +C 0.40052 -1.30745 0.03583 +H 1.55554 1.90233 -0.06290 +H -0.89474 2.35443 -0.06492 +H -2.51220 0.46563 -0.00271 +H -1.67146 -1.88124 0.06301 +H 0.77510 -2.33225 0.06522 diff --git a/autodE/source/autode/solvent/lib/bromoethane.xyz b/autodE/source/autode/solvent/lib/bromoethane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..200912508a3a5fc5a217a809f839716e920339ed --- /dev/null +++ b/autodE/source/autode/solvent/lib/bromoethane.xyz @@ -0,0 +1,10 @@ +8 +Generated by autodE on: 2021-10-13. E = -2652.618047 Ha +C -0.80238 0.04538 0.08015 +C 0.68168 -0.20879 0.02154 +Br 1.62029 1.23603 -0.89274 +H -1.02072 1.00623 0.56845 +H -1.29655 -0.75008 0.66012 +H -1.24641 0.06418 -0.92529 +H 1.13519 -0.26855 1.01923 +H 0.92880 -1.12430 -0.53146 diff --git a/autodE/source/autode/solvent/lib/bromoform.xyz b/autodE/source/autode/solvent/lib/bromoform.xyz new file mode 100644 index 0000000000000000000000000000000000000000..42dd095cf6e2d97e07033ce5bc981838c8ebfe9c --- /dev/null +++ b/autodE/source/autode/solvent/lib/bromoform.xyz @@ -0,0 +1,7 @@ +5 +Generated by autodE on: 2021-10-13. E = -7759.257753 Ha +Br -1.50849 -1.07292 -0.40355 +C 0.00280 -0.00016 0.11799 +Br -0.18780 1.82915 -0.45149 +Br 1.66759 -0.76268 -0.47421 +H 0.02600 0.00661 1.21126 diff --git a/autodE/source/autode/solvent/lib/butanenitrile.xyz b/autodE/source/autode/solvent/lib/butanenitrile.xyz new file mode 100644 index 0000000000000000000000000000000000000000..afdf6ecac81696b90bf4ee4970dc12d1b52cade5 --- /dev/null +++ b/autodE/source/autode/solvent/lib/butanenitrile.xyz @@ -0,0 +1,14 @@ +12 +Generated by autodE on: 2021-10-13. E = -210.974550 Ha +C -1.19845 0.11487 0.75550 +C 0.02140 -0.70575 0.37671 +C 0.73385 -0.20720 -0.88551 +C 1.25502 1.14886 -0.73917 +N 1.64420 2.22845 -0.58654 +H -0.92717 1.16268 0.95967 +H -1.94929 0.10992 -0.05081 +H -1.67663 -0.29022 1.65928 +H -0.27069 -1.75323 0.19672 +H 0.74502 -0.71647 1.20843 +H 0.04731 -0.22271 -1.74868 +H 1.57533 -0.86919 -1.14562 diff --git a/autodE/source/autode/solvent/lib/butanoic acid.xyz b/autodE/source/autode/solvent/lib/butanoic acid.xyz new file mode 100644 index 0000000000000000000000000000000000000000..cae6d51b9790bb1c1a7e0125eb04c74d6bff449c --- /dev/null +++ b/autodE/source/autode/solvent/lib/butanoic acid.xyz @@ -0,0 +1,16 @@ +14 +Generated by autodE on: 2021-10-13. E = -307.148999 Ha +C -2.01619 0.25468 0.08060 +C -0.59128 0.26805 -0.44125 +C 0.38106 -0.47258 0.47964 +C 1.77547 -0.44708 -0.07440 +O 2.39850 0.71081 0.20023 +O 2.28796 -1.31815 -0.72621 +H -2.68936 0.81925 -0.58018 +H -2.07791 0.70981 1.08217 +H -2.40502 -0.77291 0.15562 +H -0.24160 1.30665 -0.56312 +H -0.55409 -0.19307 -1.44187 +H 0.38228 -0.00399 1.47591 +H 0.08415 -1.52596 0.58250 +H 3.26614 0.66469 -0.22984 diff --git a/autodE/source/autode/solvent/lib/butanone.xyz b/autodE/source/autode/solvent/lib/butanone.xyz new file mode 100644 index 0000000000000000000000000000000000000000..89604f8a88d927ba859e9701cce23cc367822190 --- /dev/null +++ b/autodE/source/autode/solvent/lib/butanone.xyz @@ -0,0 +1,15 @@ +13 +Generated by autodE on: 2021-10-13. E = -232.021422 Ha +C -1.52387 0.35711 0.16245 +C -0.51411 -0.61055 -0.43393 +C 0.86894 -0.55911 0.19381 +C 1.62157 0.72949 0.01684 +O 1.22805 1.69022 -0.58963 +H -1.66954 0.17555 1.24007 +H -1.19341 1.39707 0.02805 +H -2.50278 0.24801 -0.32752 +H -0.89567 -1.64151 -0.34267 +H -0.41478 -0.40658 -1.51326 +H 0.84006 -0.77066 1.28011 +H 1.52272 -1.35035 -0.21954 +H 2.63282 0.74133 0.50533 diff --git a/autodE/source/autode/solvent/lib/butantal.xyz b/autodE/source/autode/solvent/lib/butantal.xyz new file mode 100644 index 0000000000000000000000000000000000000000..89604f8a88d927ba859e9701cce23cc367822190 --- /dev/null +++ b/autodE/source/autode/solvent/lib/butantal.xyz @@ -0,0 +1,15 @@ +13 +Generated by autodE on: 2021-10-13. E = -232.021422 Ha +C -1.52387 0.35711 0.16245 +C -0.51411 -0.61055 -0.43393 +C 0.86894 -0.55911 0.19381 +C 1.62157 0.72949 0.01684 +O 1.22805 1.69022 -0.58963 +H -1.66954 0.17555 1.24007 +H -1.19341 1.39707 0.02805 +H -2.50278 0.24801 -0.32752 +H -0.89567 -1.64151 -0.34267 +H -0.41478 -0.40658 -1.51326 +H 0.84006 -0.77066 1.28011 +H 1.52272 -1.35035 -0.21954 +H 2.63282 0.74133 0.50533 diff --git a/autodE/source/autode/solvent/lib/butyl ethanoate.xyz b/autodE/source/autode/solvent/lib/butyl ethanoate.xyz new file mode 100644 index 0000000000000000000000000000000000000000..8bd881be5d81874e3fcb03df7655400c6012ae7c --- /dev/null +++ b/autodE/source/autode/solvent/lib/butyl ethanoate.xyz @@ -0,0 +1,22 @@ +20 +Generated by autodE on: 2021-10-13. E = -385.618774 Ha +C -3.22505 0.86116 0.88595 +C -2.49277 -0.28894 0.26072 +O -1.16804 -0.15400 0.41085 +C -0.35152 -1.11417 -0.25162 +C 1.09162 -0.89760 0.14005 +C 1.60485 0.51106 -0.12494 +C 3.09551 0.65321 0.13037 +O -3.00284 -1.20946 -0.32115 +H -4.30452 0.68419 0.82355 +H -2.91261 0.99300 1.93162 +H -2.96661 1.78620 0.34951 +H -0.69842 -2.12657 0.00886 +H -0.48776 -0.98953 -1.34072 +H 1.69381 -1.62732 -0.42733 +H 1.23071 -1.15197 1.20540 +H 1.04056 1.21545 0.50564 +H 1.37463 0.79492 -1.16754 +H 3.68271 0.00967 -0.54297 +H 3.43917 1.68748 -0.02351 +H 3.35648 0.36313 1.16166 diff --git a/autodE/source/autode/solvent/lib/butylamine.xyz b/autodE/source/autode/solvent/lib/butylamine.xyz new file mode 100644 index 0000000000000000000000000000000000000000..e939bc442ab210720e7c6f02481d5b27eca24430 --- /dev/null +++ b/autodE/source/autode/solvent/lib/butylamine.xyz @@ -0,0 +1,18 @@ +16 +Generated by autodE on: 2021-10-13. E = -213.392078 Ha +N -2.62083 0.06046 0.61750 +C -1.40029 -0.60290 0.20743 +C -0.19600 0.31725 0.31992 +C 1.10321 -0.36286 -0.08180 +C 2.32081 0.54449 -0.02498 +H -3.41473 -0.57077 0.53631 +H -2.81709 0.84402 -0.00310 +H -1.24074 -1.47298 0.86740 +H -1.42943 -1.01438 -0.82633 +H -0.35484 1.20656 -0.31788 +H -0.13391 0.69642 1.35455 +H 1.26569 -1.24086 0.56724 +H 0.99479 -0.76793 -1.10378 +H 2.18997 1.43061 -0.66617 +H 2.50383 0.90991 0.99829 +H 3.22956 0.02296 -0.36261 diff --git a/autodE/source/autode/solvent/lib/carbon tetrachloride.xyz b/autodE/source/autode/solvent/lib/carbon tetrachloride.xyz new file mode 100644 index 0000000000000000000000000000000000000000..d62b0bfa5f65d8dda9a0f4d57e9367d6a0c1d36e --- /dev/null +++ b/autodE/source/autode/solvent/lib/carbon tetrachloride.xyz @@ -0,0 +1,7 @@ +5 +Generated by autodE on: 2021-10-13. E = -1877.627551 Ha +Cl 1.52056 -0.89009 -0.14556 +C -0.00051 0.00029 0.00122 +Cl 0.27005 1.69713 -0.41432 +Cl -0.59470 -0.11478 1.66201 +Cl -1.19540 -0.69245 -1.10335 diff --git a/autodE/source/autode/solvent/lib/chlorobenzene.xyz b/autodE/source/autode/solvent/lib/chlorobenzene.xyz new file mode 100644 index 0000000000000000000000000000000000000000..7397a6751000b16ead0de08e7f144a2d6ac4adc4 --- /dev/null +++ b/autodE/source/autode/solvent/lib/chlorobenzene.xyz @@ -0,0 +1,14 @@ +12 +Generated by autodE on: 2021-10-13. E = -691.122337 Ha +Cl 3.02435 -0.47321 0.00163 +C 1.31303 -0.20102 -0.00052 +C 0.45125 -1.29552 -0.04667 +C -0.92404 -1.07624 -0.04591 +C -1.43333 0.22059 0.00078 +C -0.55930 1.30527 0.04564 +C 0.81910 1.10216 0.04552 +H 0.86389 -2.30497 -0.08527 +H -1.60096 -1.93243 -0.08454 +H -2.51277 0.38855 0.00146 +H -0.95431 2.32350 0.08421 +H 1.51310 1.94331 0.08368 diff --git a/autodE/source/autode/solvent/lib/cis-1,2-dichloroethene.xyz b/autodE/source/autode/solvent/lib/cis-1,2-dichloroethene.xyz new file mode 100644 index 0000000000000000000000000000000000000000..e11e904631d909a63d48da2a530e94fb1f2053fe --- /dev/null +++ b/autodE/source/autode/solvent/lib/cis-1,2-dichloroethene.xyz @@ -0,0 +1,8 @@ +6 +Generated by autodE on: 2021-10-13. E = -997.046938 Ha +Cl 1.61966 1.11446 0.31989 +C 0.61174 -0.21388 -0.06646 +C -0.63507 -0.10163 -0.52716 +Cl -1.42976 1.38831 -0.80572 +H 1.05983 -1.19727 0.09187 +H -1.22641 -0.98999 -0.75652 diff --git a/autodE/source/autode/solvent/lib/cis-1,2-dimethylcyclohexane.xyz b/autodE/source/autode/solvent/lib/cis-1,2-dimethylcyclohexane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..59c86d7142852916061862d8e54d40ba2bea6367 --- /dev/null +++ b/autodE/source/autode/solvent/lib/cis-1,2-dimethylcyclohexane.xyz @@ -0,0 +1,26 @@ +24 +Generated by autodE on: 2021-10-13. E = -313.908887 Ha +C -1.89441 -0.67290 -0.84495 +C -0.83871 -0.62402 0.25332 +C -0.26641 0.79485 0.50325 +C -1.29835 1.90451 0.37287 +C 0.95443 1.02699 -0.38102 +C 2.11681 0.11332 0.02238 +C 1.62886 -1.24789 0.54374 +C 0.28673 -1.62676 -0.06913 +H -2.21148 -1.71094 -1.03174 +H -2.79452 -0.09534 -0.58947 +H -1.49647 -0.27986 -1.79575 +H -1.32260 -0.94288 1.19407 +H 0.09473 0.81182 1.54783 +H -1.63283 2.03462 -0.66865 +H -2.19179 1.70321 0.98372 +H -0.87558 2.86539 0.70456 +H 1.26886 2.08249 -0.33002 +H 0.66684 0.84883 -1.43339 +H 2.72181 0.60959 0.79903 +H 2.78826 -0.02750 -0.84023 +H 1.53027 -1.21842 1.64254 +H 2.37626 -2.02789 0.32755 +H 0.40911 -1.68642 -1.16591 +H -0.01993 -2.63472 0.25531 diff --git a/autodE/source/autode/solvent/lib/cis-decalin.xyz b/autodE/source/autode/solvent/lib/cis-decalin.xyz new file mode 100644 index 0000000000000000000000000000000000000000..578cff47422338f0778ffa7bef7513a67d34e85d --- /dev/null +++ b/autodE/source/autode/solvent/lib/cis-decalin.xyz @@ -0,0 +1,30 @@ +28 +Generated by autodE on: 2021-10-13. E = -391.199240 Ha +C -0.24051 0.65119 0.71978 +C -1.61595 0.74507 0.05551 +C -1.60979 1.53879 -1.24083 +C -0.59913 0.95959 -2.22142 +C 0.78868 0.93057 -1.59511 +C 0.85529 0.17958 -0.26317 +C 0.82699 -1.34251 -0.39784 +C 1.14046 -2.03494 0.94458 +C 0.95010 -1.10049 2.14270 +C -0.30654 -0.25697 1.96838 +H 0.04005 1.67163 1.03862 +H -2.33160 1.17956 0.77340 +H -1.98283 -0.27648 -0.15549 +H -1.34804 2.59081 -1.02380 +H -2.61893 1.55054 -1.68463 +H -0.57957 1.54564 -3.15401 +H -0.91304 -0.05998 -2.50750 +H 1.10849 1.97350 -1.42008 +H 1.52163 0.49983 -2.29687 +H 1.82957 0.43859 0.18683 +H 1.54295 -1.66066 -1.17196 +H -0.16238 -1.66071 -0.76677 +H 2.17034 -2.42674 0.94330 +H 0.48805 -2.91415 1.06730 +H 1.82399 -0.43689 2.25357 +H 0.90094 -1.69001 3.07236 +H -0.51216 0.34831 2.86591 +H -1.16735 -0.94267 1.86732 diff --git a/autodE/source/autode/solvent/lib/cs2.xyz b/autodE/source/autode/solvent/lib/cs2.xyz new file mode 100644 index 0000000000000000000000000000000000000000..408b8f8224404904269cb45143ddea8fd30237bf --- /dev/null +++ b/autodE/source/autode/solvent/lib/cs2.xyz @@ -0,0 +1,5 @@ +3 +Generated by autodE on: 2021-10-13. E = -833.890464 Ha +S -1.55571 -0.00030 -0.00002 +C 0.00000 0.00000 0.00001 +S 1.55570 0.00029 0.00002 diff --git a/autodE/source/autode/solvent/lib/cyclohexane.xyz b/autodE/source/autode/solvent/lib/cyclohexane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..fa2efb92b2067bcc2201cfe3d0153c66a7a519e6 --- /dev/null +++ b/autodE/source/autode/solvent/lib/cyclohexane.xyz @@ -0,0 +1,20 @@ +18 +Generated by autodE on: 2021-10-13. E = -235.436817 Ha +C 1.01637 0.95666 0.47736 +C -0.16153 1.41955 -0.37253 +C -1.36892 0.50324 -0.20519 +C -1.01638 -0.95664 -0.47790 +C 0.16134 -1.41964 0.37244 +C 1.36866 -0.50341 0.20575 +H 0.75740 1.07542 1.54619 +H 1.89361 1.59938 0.29653 +H 0.14697 1.42825 -1.43445 +H -0.43502 2.45712 -0.11874 +H -1.74818 0.59432 0.82908 +H -2.18855 0.83043 -0.86475 +H -0.75592 -1.07399 -1.54657 +H -1.89364 -1.59966 -0.29857 +H -0.14800 -1.42848 1.43412 +H 0.43470 -2.45726 0.11870 +H 1.74942 -0.59536 -0.82786 +H 2.18756 -0.83004 0.86659 diff --git a/autodE/source/autode/solvent/lib/cyclohexanone.xyz b/autodE/source/autode/solvent/lib/cyclohexanone.xyz new file mode 100644 index 0000000000000000000000000000000000000000..69f79b88f902eaa03a5f45f5f4daa16ce9e3ab47 --- /dev/null +++ b/autodE/source/autode/solvent/lib/cyclohexanone.xyz @@ -0,0 +1,19 @@ +17 +Generated by autodE on: 2021-10-13. E = -309.320036 Ha +O 2.56492 0.34150 -0.85365 +C 1.52097 0.13402 -0.28285 +C 0.85905 -1.22742 -0.26321 +C -0.63819 -1.15647 -0.57108 +C -1.34216 -0.14010 0.31533 +C -0.71199 1.23865 0.17855 +C 0.78648 1.20845 0.48984 +H 0.99708 -1.63790 0.75464 +H 1.40279 -1.88266 -0.95729 +H -1.09279 -2.15322 -0.45559 +H -0.77805 -0.87039 -1.62875 +H -1.28343 -0.47273 1.36853 +H -2.41308 -0.09246 0.05986 +H -1.21529 1.96067 0.83959 +H -0.86129 1.60592 -0.85164 +H 1.27962 2.17116 0.29551 +H 0.92526 0.97305 1.56230 diff --git a/autodE/source/autode/solvent/lib/cyclopentane.xyz b/autodE/source/autode/solvent/lib/cyclopentane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..0a4f11f34d51a6427fbbcb5829418b6c6d799eb1 --- /dev/null +++ b/autodE/source/autode/solvent/lib/cyclopentane.xyz @@ -0,0 +1,17 @@ +15 +Generated by autodE on: 2021-10-13. E = -196.184661 Ha +C 1.17176 0.54296 0.05602 +C -0.18483 1.25448 0.18250 +C -1.17256 0.13277 0.48541 +C -0.68510 -0.99950 -0.41291 +C 0.84480 -0.92653 -0.30720 +H 1.81732 1.02678 -0.69168 +H 1.71914 0.58989 1.01061 +H -0.46012 1.72569 -0.77602 +H -0.18386 2.05277 0.94036 +H -2.22085 0.41964 0.30661 +H -1.08934 -0.17377 1.54388 +H -1.08935 -1.98449 -0.13282 +H -1.00598 -0.80306 -1.45042 +H 1.33608 -1.25255 -1.23604 +H 1.20300 -1.60509 0.48188 diff --git a/autodE/source/autode/solvent/lib/cyclopentanol.xyz b/autodE/source/autode/solvent/lib/cyclopentanol.xyz new file mode 100644 index 0000000000000000000000000000000000000000..f7910d010e5fed9d9cfa13bb5d04c270ffa74d85 --- /dev/null +++ b/autodE/source/autode/solvent/lib/cyclopentanol.xyz @@ -0,0 +1,18 @@ +16 +Generated by autodE on: 2021-10-13. E = -271.269286 Ha +O 2.39808 0.11170 0.16826 +C 1.07884 0.05900 -0.28605 +C 0.17189 1.19662 0.18069 +C -1.24007 0.72327 -0.18086 +C -1.16648 -0.82703 -0.22109 +C 0.28510 -1.18388 0.11745 +H 2.37711 0.08502 1.13211 +H 1.14881 0.09241 -1.38802 +H 0.44804 2.16625 -0.25836 +H 0.28058 1.29389 1.27716 +H -1.99028 1.08755 0.53513 +H -1.53984 1.11791 -1.16339 +H -1.87619 -1.29716 0.47400 +H -1.43175 -1.19807 -1.22273 +H 0.40706 -1.32874 1.20728 +H 0.64910 -2.09874 -0.37167 diff --git a/autodE/source/autode/solvent/lib/cyclopentanone.xyz b/autodE/source/autode/solvent/lib/cyclopentanone.xyz new file mode 100644 index 0000000000000000000000000000000000000000..57bc6dd905ed9c4b8efd8ddeac7e04d03c74f030 --- /dev/null +++ b/autodE/source/autode/solvent/lib/cyclopentanone.xyz @@ -0,0 +1,16 @@ +14 +Generated by autodE on: 2021-10-13. E = -270.073565 Ha +O 2.25080 1.09211 -0.52283 +C 1.18138 0.58598 -0.30947 +C 0.88213 -0.90879 -0.35456 +C -0.62465 -1.03347 -0.14949 +C -1.00049 0.22529 0.64504 +C -0.09992 1.31423 0.07470 +H 1.27588 -1.35269 -1.28069 +H 1.44730 -1.36993 0.47366 +H -0.92115 -1.95914 0.36534 +H -1.14300 -1.02974 -1.12194 +H -2.07170 0.46745 0.58162 +H -0.76272 0.07218 1.71228 +H -0.52442 1.73443 -0.85526 +H 0.11068 2.16199 0.74139 diff --git a/autodE/source/autode/solvent/lib/decalin mix.xyz b/autodE/source/autode/solvent/lib/decalin mix.xyz new file mode 100644 index 0000000000000000000000000000000000000000..578cff47422338f0778ffa7bef7513a67d34e85d --- /dev/null +++ b/autodE/source/autode/solvent/lib/decalin mix.xyz @@ -0,0 +1,30 @@ +28 +Generated by autodE on: 2021-10-13. E = -391.199240 Ha +C -0.24051 0.65119 0.71978 +C -1.61595 0.74507 0.05551 +C -1.60979 1.53879 -1.24083 +C -0.59913 0.95959 -2.22142 +C 0.78868 0.93057 -1.59511 +C 0.85529 0.17958 -0.26317 +C 0.82699 -1.34251 -0.39784 +C 1.14046 -2.03494 0.94458 +C 0.95010 -1.10049 2.14270 +C -0.30654 -0.25697 1.96838 +H 0.04005 1.67163 1.03862 +H -2.33160 1.17956 0.77340 +H -1.98283 -0.27648 -0.15549 +H -1.34804 2.59081 -1.02380 +H -2.61893 1.55054 -1.68463 +H -0.57957 1.54564 -3.15401 +H -0.91304 -0.05998 -2.50750 +H 1.10849 1.97350 -1.42008 +H 1.52163 0.49983 -2.29687 +H 1.82957 0.43859 0.18683 +H 1.54295 -1.66066 -1.17196 +H -0.16238 -1.66071 -0.76677 +H 2.17034 -2.42674 0.94330 +H 0.48805 -2.91415 1.06730 +H 1.82399 -0.43689 2.25357 +H 0.90094 -1.69001 3.07236 +H -0.51216 0.34831 2.86591 +H -1.16735 -0.94267 1.86732 diff --git a/autodE/source/autode/solvent/lib/decane.xyz b/autodE/source/autode/solvent/lib/decane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..4e18c2289fdd86cf1040bb5ab7aad9b27d00583d --- /dev/null +++ b/autodE/source/autode/solvent/lib/decane.xyz @@ -0,0 +1,34 @@ +32 +Generated by autodE on: 2021-10-13. E = -393.584924 Ha +C -5.02484 1.43600 -0.26805 +C -3.58202 1.05493 0.02514 +C -3.30264 -0.42423 -0.21024 +C -1.96562 -0.92388 0.32605 +C -0.71120 -0.30506 -0.27727 +C 0.55853 -0.83119 0.37766 +C 1.86122 -0.32413 -0.22270 +C 3.08588 -0.75239 0.57926 +C 4.42556 -0.54288 -0.12138 +C 4.75656 0.90661 -0.44253 +H -5.20806 2.51017 -0.10940 +H -5.71946 0.88352 0.38316 +H -5.29883 1.19679 -1.30899 +H -2.90503 1.66920 -0.59219 +H -3.33861 1.30902 1.07289 +H -4.10255 -1.01217 0.27264 +H -3.38475 -0.64800 -1.29024 +H -1.91634 -2.01814 0.18000 +H -1.94369 -0.76345 1.42081 +H -0.68521 -0.51279 -1.36372 +H -0.73912 0.79459 -0.17078 +H 0.53336 -0.57289 1.45297 +H 0.55471 -1.93628 0.33268 +H 1.95225 -0.69747 -1.25948 +H 1.82661 0.77686 -0.29638 +H 2.98539 -1.82209 0.83503 +H 3.08954 -0.21638 1.54538 +H 4.43687 -1.13592 -1.05312 +H 5.22379 -0.96954 0.50949 +H 4.79098 1.52056 0.47190 +H 4.01130 1.35795 -1.11560 +H 5.73543 0.99275 -0.93920 diff --git a/autodE/source/autode/solvent/lib/dibromomethane.xyz b/autodE/source/autode/solvent/lib/dibromomethane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..6f235603af26b48b5c3688cdd3a84371cbd4bb2b --- /dev/null +++ b/autodE/source/autode/solvent/lib/dibromomethane.xyz @@ -0,0 +1,7 @@ +5 +Generated by autodE on: 2021-10-13. E = -5186.318306 Ha +Br 1.63158 0.83626 -0.03948 +C -0.00312 -0.17046 0.00567 +Br -1.59429 0.90664 -0.02001 +H -0.01033 -0.75499 0.93051 +H -0.02385 -0.81745 -0.87659 diff --git a/autodE/source/autode/solvent/lib/dibutylether.xyz b/autodE/source/autode/solvent/lib/dibutylether.xyz new file mode 100644 index 0000000000000000000000000000000000000000..dbeb2829d05a94aa7cb8fe04d500cd3eecaaaee0 --- /dev/null +++ b/autodE/source/autode/solvent/lib/dibutylether.xyz @@ -0,0 +1,29 @@ +27 +Generated by autodE on: 2021-10-13. E = -390.178252 Ha +C -4.81232 -0.41763 -0.25352 +C -3.52356 -0.28245 0.54102 +C -2.29390 -0.06857 -0.32882 +C -1.01382 -0.01450 0.47382 +O 0.07380 0.12981 -0.38849 +C 1.31093 -0.00195 0.24580 +C 2.41488 0.13312 -0.78127 +C 3.80857 -0.15412 -0.23389 +C 4.23357 0.75104 0.91061 +H -4.76536 -1.26855 -0.95197 +H -5.00995 0.48466 -0.85427 +H -5.67742 -0.57610 0.40780 +H -3.37654 -1.18657 1.15898 +H -3.61639 0.55322 1.25783 +H -2.39017 0.86334 -0.91186 +H -2.19983 -0.88427 -1.06653 +H -1.04857 0.82041 1.20768 +H -0.91140 -0.94654 1.07057 +H 1.38235 -0.99121 0.74935 +H 1.41768 0.76001 1.04529 +H 2.37620 1.14957 -1.20924 +H 2.18569 -0.55823 -1.60794 +H 4.53251 -0.05953 -1.06038 +H 3.86144 -1.20709 0.09834 +H 5.26969 0.54653 1.22068 +H 4.17216 1.81216 0.61642 +H 3.59945 0.61353 1.79997 diff --git a/autodE/source/autode/solvent/lib/dichloromethane.xyz b/autodE/source/autode/solvent/lib/dichloromethane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..1faf313e7ea124bf10ec316e91504be84164774c --- /dev/null +++ b/autodE/source/autode/solvent/lib/dichloromethane.xyz @@ -0,0 +1,7 @@ +5 +Generated by autodE on: 2021-10-13. E = -959.032984 Ha +Cl 1.49950 0.78396 -0.06450 +C -0.00353 -0.13780 0.00923 +Cl -1.45367 0.86821 -0.04369 +H -0.01286 -0.69919 0.95006 +H -0.02955 -0.81518 -0.85110 diff --git a/autodE/source/autode/solvent/lib/diethyl ether.xyz b/autodE/source/autode/solvent/lib/diethyl ether.xyz new file mode 100644 index 0000000000000000000000000000000000000000..ced3501e4f56ceda367269275e44273db17de878 --- /dev/null +++ b/autodE/source/autode/solvent/lib/diethyl ether.xyz @@ -0,0 +1,17 @@ +15 +Generated by autodE on: 2021-10-13. E = -233.215047 Ha +C 1.67600 0.63996 0.44165 +C 1.11814 -0.75902 0.22701 +O 0.20956 -0.85489 -0.83809 +C -1.15034 -0.73946 -0.52257 +C -1.61939 0.68326 -0.26757 +H 0.91291 1.34410 0.80025 +H 2.08240 1.03313 -0.50229 +H 2.49130 0.62209 1.18189 +H 1.95004 -1.44171 -0.01462 +H 0.65430 -1.14077 1.15945 +H -1.69363 -1.15588 -1.38613 +H -1.39798 -1.38518 0.34573 +H -1.23075 1.08696 0.67908 +H -2.71896 0.72607 -0.22041 +H -1.28369 1.34133 -1.08338 diff --git a/autodE/source/autode/solvent/lib/diethyl sulfide.xyz b/autodE/source/autode/solvent/lib/diethyl sulfide.xyz new file mode 100644 index 0000000000000000000000000000000000000000..2794d8021113f413e95c088b49ac8d80a6995d48 --- /dev/null +++ b/autodE/source/autode/solvent/lib/diethyl sulfide.xyz @@ -0,0 +1,17 @@ +15 +Generated by autodE on: 2021-10-13. E = -556.072627 Ha +C 1.78941 0.90567 0.13494 +C 1.33891 -0.53079 0.34317 +S 0.34582 -1.20986 -1.02331 +C -1.33913 -0.99030 -0.38489 +C -1.76764 0.44490 -0.14781 +H 2.41255 1.25090 0.97633 +H 0.93210 1.58707 0.03899 +H 2.38424 0.99969 -0.78613 +H 2.21165 -1.19484 0.44104 +H 0.75485 -0.64997 1.27026 +H -1.97224 -1.46883 -1.14913 +H -1.44790 -1.59509 0.53128 +H -1.15488 0.92507 0.62894 +H -2.81502 0.48544 0.19357 +H -1.67260 1.04104 -1.06725 diff --git a/autodE/source/autode/solvent/lib/diethylamine.xyz b/autodE/source/autode/solvent/lib/diethylamine.xyz new file mode 100644 index 0000000000000000000000000000000000000000..2c2a505b6a69cb0d7af0f5335faea38b981bfb2d --- /dev/null +++ b/autodE/source/autode/solvent/lib/diethylamine.xyz @@ -0,0 +1,18 @@ +16 +Generated by autodE on: 2021-10-13. E = -213.386562 Ha +C -2.18233 0.01990 0.17952 +C -1.06595 -0.95973 0.53091 +N 0.24802 -0.39755 0.74773 +C 0.89766 0.19773 -0.39887 +C 2.31704 0.61528 -0.05280 +H -2.30052 0.78587 0.96351 +H -1.99140 0.54471 -0.76962 +H -3.14614 -0.50474 0.08044 +H -1.35278 -1.51907 1.43863 +H -0.97912 -1.71964 -0.26676 +H 0.23305 0.26007 1.52516 +H 0.91957 -0.56211 -1.20092 +H 0.35341 1.06749 -0.83084 +H 2.84948 1.01263 -0.92999 +H 2.32280 1.40274 0.71914 +H 2.87730 -0.24358 0.34668 diff --git a/autodE/source/autode/solvent/lib/diiodomethane.xyz b/autodE/source/autode/solvent/lib/diiodomethane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..28e66ecfad1cdae7651447b2cbea239ca7e73373 --- /dev/null +++ b/autodE/source/autode/solvent/lib/diiodomethane.xyz @@ -0,0 +1,7 @@ +5 +Generated by autodE on: 2021-10-13. E = -634.649583 Ha +I 1.82641 0.89778 -0.03439 +C -0.00252 -0.20643 0.00581 +I -1.79568 0.95728 -0.01728 +H -0.00932 -0.79949 0.92565 +H -0.01900 -0.84915 -0.87989 diff --git a/autodE/source/autode/solvent/lib/diisopropyl ether.xyz b/autodE/source/autode/solvent/lib/diisopropyl ether.xyz new file mode 100644 index 0000000000000000000000000000000000000000..ed6702ac5bfa1c30f4247a3901fb0749d96df54f --- /dev/null +++ b/autodE/source/autode/solvent/lib/diisopropyl ether.xyz @@ -0,0 +1,23 @@ +21 +Generated by autodE on: 2021-10-13. E = -311.706986 Ha +C 1.60745 -0.76687 -1.38800 +C 1.18819 -0.15550 -0.05801 +O -0.02220 0.55916 -0.20201 +C -1.20468 -0.16983 0.04442 +C -2.32932 0.55635 -0.66389 +C -1.47875 -0.30723 1.53638 +C 2.21905 0.80739 0.49459 +H 0.81391 -1.41281 -1.79253 +H 2.51779 -1.37644 -1.27980 +H 1.80811 0.02876 -2.12143 +H 1.04800 -0.97253 0.67732 +H -1.10655 -1.18422 -0.39493 +H -3.28867 0.04041 -0.50948 +H -2.41586 1.58004 -0.26735 +H -2.12858 0.62317 -1.74273 +H -0.63814 -0.78284 2.06289 +H -1.64680 0.68386 1.98575 +H -2.37098 -0.92607 1.71673 +H 2.38617 1.63416 -0.21229 +H 1.86249 1.24018 1.44089 +H 3.17934 0.30094 0.67368 diff --git a/autodE/source/autode/solvent/lib/dimethyl disulfide.xyz b/autodE/source/autode/solvent/lib/dimethyl disulfide.xyz new file mode 100644 index 0000000000000000000000000000000000000000..e1f01afff7a8e13471fa236a8aac621526b03f59 --- /dev/null +++ b/autodE/source/autode/solvent/lib/dimethyl disulfide.xyz @@ -0,0 +1,12 @@ +10 +Generated by autodE on: 2021-10-13. E = -875.528102 Ha +C 1.83121 -0.03489 -0.04132 +S 0.70333 1.33952 0.30074 +S -0.96589 0.36759 1.00562 +C -1.80928 -0.11220 -0.52594 +H 1.42208 -0.70517 -0.80997 +H 2.05165 -0.60193 0.87359 +H 2.75958 0.41695 -0.42317 +H -2.70190 -0.67867 -0.21931 +H -1.17432 -0.76292 -1.14347 +H -2.11654 0.77183 -1.10067 diff --git a/autodE/source/autode/solvent/lib/dioxane.xyz b/autodE/source/autode/solvent/lib/dioxane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..cebb7f2c5cf4073229e830a8b99314889e2c199f --- /dev/null +++ b/autodE/source/autode/solvent/lib/dioxane.xyz @@ -0,0 +1,16 @@ +14 +Generated by autodE on: 2021-10-13. E = -307.083680 Ha +O -0.43604 -1.17005 0.69085 +C 0.94262 -0.88626 0.65425 +C 1.21204 0.48698 0.02952 +O 0.17646 1.36539 0.36483 +C -1.05156 0.97308 -0.20225 +C -1.13455 -0.54898 -0.34792 +H 1.29282 -0.90722 1.69780 +H 1.49578 -1.67042 0.09779 +H 2.14937 0.91009 0.41950 +H 1.32710 0.41117 -1.07178 +H -1.83486 1.34132 0.47591 +H -1.20965 1.45480 -1.18971 +H -0.74802 -0.87938 -1.33372 +H -2.18130 -0.88050 -0.28467 diff --git a/autodE/source/autode/solvent/lib/diphenylether.xyz b/autodE/source/autode/solvent/lib/diphenylether.xyz new file mode 100644 index 0000000000000000000000000000000000000000..8be2b9a87cd56e30241719c5caa7a742518b13fc --- /dev/null +++ b/autodE/source/autode/solvent/lib/diphenylether.xyz @@ -0,0 +1,25 @@ +23 +Generated by autodE on: 2021-10-13. E = -537.524144 Ha +C 1.19454 0.57555 0.10937 +O 0.05569 1.31807 0.20960 +C -1.15852 0.73233 -0.03569 +C -1.72649 0.86894 -1.30069 +C -2.98030 0.31643 -1.55191 +C -3.65731 -0.37507 -0.54858 +C -3.08124 -0.50290 0.71546 +C -1.83093 0.05305 0.97902 +C 2.39816 1.28337 0.15872 +C 3.60567 0.59890 0.08185 +C 3.62598 -0.79132 -0.04477 +C 2.42055 -1.48575 -0.10105 +C 1.20082 -0.81353 -0.03091 +H -1.17659 1.41416 -2.06971 +H -3.43267 0.43017 -2.54002 +H -4.63752 -0.81172 -0.75077 +H -3.61039 -1.03726 1.50766 +H -1.36408 -0.03552 1.96214 +H 2.35589 2.36974 0.25953 +H 4.54236 1.16016 0.11943 +H 4.57420 -1.32920 -0.10533 +H 2.42074 -2.57352 -0.20867 +H 0.26135 -1.36507 -0.08356 diff --git a/autodE/source/autode/solvent/lib/dipropylamine.xyz b/autodE/source/autode/solvent/lib/dipropylamine.xyz new file mode 100644 index 0000000000000000000000000000000000000000..ddd45408df4f642de229557e5ab2abf91253a698 --- /dev/null +++ b/autodE/source/autode/solvent/lib/dipropylamine.xyz @@ -0,0 +1,24 @@ +22 +Generated by autodE on: 2021-10-13. E = -291.865541 Ha +C 3.43815 0.39707 0.19696 +C 1.93336 0.61064 0.18285 +C 1.16444 -0.70905 0.09416 +N -0.27023 -0.64498 0.22738 +C -1.01907 -0.04967 -0.85455 +C -2.51428 -0.30904 -0.69025 +C -3.05746 0.10120 0.67272 +H 3.73864 -0.25372 1.03408 +H 3.98808 1.34398 0.29830 +H 3.78388 -0.09377 -0.72763 +H 1.65083 1.26939 -0.65598 +H 1.62124 1.14250 1.10026 +H 1.39960 -1.20549 -0.86509 +H 1.54913 -1.38789 0.87624 +H -0.51318 -0.19524 1.10633 +H -0.85074 1.04551 -0.97378 +H -0.67440 -0.50988 -1.79625 +H -2.70495 -1.38433 -0.84345 +H -3.05802 0.22627 -1.48570 +H -2.58905 -0.49098 1.47323 +H -2.87348 1.16852 0.88527 +H -4.14247 -0.07095 0.74472 diff --git a/autodE/source/autode/solvent/lib/dmf.xyz b/autodE/source/autode/solvent/lib/dmf.xyz new file mode 100644 index 0000000000000000000000000000000000000000..7e4bae0175f9da0551b0a86a9e5224f6f968a211 --- /dev/null +++ b/autodE/source/autode/solvent/lib/dmf.xyz @@ -0,0 +1,14 @@ +12 +Generated by autodE on: 2021-10-13. E = -248.052076 Ha +O -2.28941 -0.72131 0.40169 +C -1.16110 -0.97854 0.05085 +N -0.11835 -0.10379 0.00914 +C 1.19080 -0.50442 -0.42313 +C -0.31030 1.27003 0.39393 +H -0.85299 -1.99721 -0.29083 +H 1.52058 0.07727 -1.30111 +H 1.94004 -0.36372 0.37563 +H 1.17911 -1.56736 -0.70483 +H -1.36298 1.40189 0.67657 +H -0.06505 1.95085 -0.43958 +H 0.32983 1.53610 1.25166 diff --git a/autodE/source/autode/solvent/lib/dmso.xyz b/autodE/source/autode/solvent/lib/dmso.xyz new file mode 100644 index 0000000000000000000000000000000000000000..5a9d48e7614d2b2c50a4ced891851f0851dcffd0 --- /dev/null +++ b/autodE/source/autode/solvent/lib/dmso.xyz @@ -0,0 +1,12 @@ +10 +Generated by autodE on: 2021-10-13. E = -552.629474 Ha +O -0.16996 -1.83675 -1.11223 +S -0.18755 -0.33732 -1.16693 +C -1.31967 0.19467 0.14614 +C 1.35326 0.22108 -0.38214 +H -2.30899 -0.17969 -0.15076 +H -1.33828 1.29064 0.23300 +H -1.01567 -0.28337 1.08890 +H 1.42051 1.31889 -0.40115 +H 2.16187 -0.21599 -0.98254 +H 1.40468 -0.17216 0.64392 diff --git a/autodE/source/autode/solvent/lib/e-2-pentene.xyz b/autodE/source/autode/solvent/lib/e-2-pentene.xyz new file mode 100644 index 0000000000000000000000000000000000000000..0a4f11f34d51a6427fbbcb5829418b6c6d799eb1 --- /dev/null +++ b/autodE/source/autode/solvent/lib/e-2-pentene.xyz @@ -0,0 +1,17 @@ +15 +Generated by autodE on: 2021-10-13. E = -196.184661 Ha +C 1.17176 0.54296 0.05602 +C -0.18483 1.25448 0.18250 +C -1.17256 0.13277 0.48541 +C -0.68510 -0.99950 -0.41291 +C 0.84480 -0.92653 -0.30720 +H 1.81732 1.02678 -0.69168 +H 1.71914 0.58989 1.01061 +H -0.46012 1.72569 -0.77602 +H -0.18386 2.05277 0.94036 +H -2.22085 0.41964 0.30661 +H -1.08934 -0.17377 1.54388 +H -1.08935 -1.98449 -0.13282 +H -1.00598 -0.80306 -1.45042 +H 1.33608 -1.25255 -1.23604 +H 1.20300 -1.60509 0.48188 diff --git a/autodE/source/autode/solvent/lib/ethanethiol.xyz b/autodE/source/autode/solvent/lib/ethanethiol.xyz new file mode 100644 index 0000000000000000000000000000000000000000..d0f990c550c13fa6bedbd1dd10a66b9f29db2242 --- /dev/null +++ b/autodE/source/autode/solvent/lib/ethanethiol.xyz @@ -0,0 +1,11 @@ +9 +Generated by autodE on: 2021-10-13. E = -477.596001 Ha +C -0.84878 0.45729 0.06974 +C 0.17636 -0.65807 0.05323 +S 1.69186 -0.27086 -0.87805 +H -0.44967 1.35715 0.56280 +H -1.75165 0.14583 0.61957 +H -1.14375 0.73961 -0.95153 +H 0.44993 -0.97186 1.07254 +H -0.22501 -1.55041 -0.45175 +H 2.10063 0.75132 -0.09635 diff --git a/autodE/source/autode/solvent/lib/ethanol.xyz b/autodE/source/autode/solvent/lib/ethanol.xyz new file mode 100644 index 0000000000000000000000000000000000000000..4e014dfc7bb26a920a4d1e3e40c07aabb58034f2 --- /dev/null +++ b/autodE/source/autode/solvent/lib/ethanol.xyz @@ -0,0 +1,11 @@ +9 +Generated by autodE on: 2021-10-13. E = -154.746031 Ha +C 0.89581 0.16404 0.09861 +C -0.48153 -0.46582 0.02235 +O -1.39148 0.27963 -0.74253 +H 0.86577 1.13644 0.61862 +H 1.59732 -0.47979 0.65070 +H 1.29853 0.33331 -0.91135 +H -0.86718 -0.65265 1.04581 +H -0.41309 -1.45128 -0.46493 +H -1.50414 1.13602 -0.31708 diff --git a/autodE/source/autode/solvent/lib/ethyl acetate.xyz b/autodE/source/autode/solvent/lib/ethyl acetate.xyz new file mode 100644 index 0000000000000000000000000000000000000000..5b90b61b5511826250feb3b9b9dadfb39918c186 --- /dev/null +++ b/autodE/source/autode/solvent/lib/ethyl acetate.xyz @@ -0,0 +1,16 @@ +14 +Generated by autodE on: 2021-10-13. E = -307.139065 Ha +C -2.39030 0.20271 0.36627 +C -1.02142 -0.38703 0.20096 +O -0.09933 0.36443 0.81873 +C 1.24836 -0.10461 0.78516 +C 1.92035 0.21156 -0.53204 +O -0.77068 -1.40934 -0.38249 +H -2.70772 0.08321 1.41384 +H -3.09610 -0.31636 -0.28961 +H -2.37494 1.27962 0.14610 +H 1.74589 0.40508 1.62203 +H 1.25623 -1.18904 0.97671 +H 2.96834 -0.12436 -0.50983 +H 1.90768 1.29407 -0.73006 +H 1.41355 -0.31014 -1.35466 diff --git a/autodE/source/autode/solvent/lib/ethyl benzene.xyz b/autodE/source/autode/solvent/lib/ethyl benzene.xyz new file mode 100644 index 0000000000000000000000000000000000000000..4f9ffbc0f18280f5a1af80cc7ddf33672638501a --- /dev/null +++ b/autodE/source/autode/solvent/lib/ethyl benzene.xyz @@ -0,0 +1,20 @@ +18 +Generated by autodE on: 2021-10-13. E = -310.296181 Ha +C -2.47254 0.05823 0.61012 +C -1.62946 -0.12628 -0.64914 +C -0.15019 -0.06102 -0.37724 +C 0.51444 -1.15219 0.19759 +C 1.87355 -1.08808 0.49684 +C 2.59340 0.07635 0.22778 +C 1.94700 1.16935 -0.34555 +C 0.58600 1.09897 -0.64310 +H -2.27037 1.03195 1.08403 +H -3.54738 0.00557 0.37640 +H -2.24850 -0.71738 1.35831 +H -1.87423 -1.09690 -1.11250 +H -1.89826 0.64528 -1.38770 +H -0.04803 -2.06531 0.41648 +H 2.37539 -1.95037 0.94331 +H 3.65801 0.13080 0.46810 +H 2.50618 2.08161 -0.56802 +H 0.08489 1.95952 -1.09560 diff --git a/autodE/source/autode/solvent/lib/ethyl methanoate.xyz b/autodE/source/autode/solvent/lib/ethyl methanoate.xyz new file mode 100644 index 0000000000000000000000000000000000000000..4d364b885454f1405efc24f035b8faaf909abac6 --- /dev/null +++ b/autodE/source/autode/solvent/lib/ethyl methanoate.xyz @@ -0,0 +1,13 @@ +11 +Generated by autodE on: 2021-10-13. E = -267.887141 Ha +O 2.32883 -0.97209 -0.35416 +C 2.01985 0.07500 0.13854 +O 0.77664 0.49051 0.35727 +C -0.26604 -0.39793 -0.05500 +C -1.56853 0.35786 -0.03717 +H 2.73359 0.85273 0.48202 +H -0.28207 -1.26226 0.62902 +H -0.02391 -0.79109 -1.05458 +H -2.38756 -0.30549 -0.35129 +H -1.79535 0.73750 0.97003 +H -1.53546 1.21527 -0.72468 diff --git a/autodE/source/autode/solvent/lib/ethyl phenyl ether.xyz b/autodE/source/autode/solvent/lib/ethyl phenyl ether.xyz new file mode 100644 index 0000000000000000000000000000000000000000..8783ec7bd4b8e93f75af40c9a7bf5cfe45b50892 --- /dev/null +++ b/autodE/source/autode/solvent/lib/ethyl phenyl ether.xyz @@ -0,0 +1,21 @@ +19 +Generated by autodE on: 2021-10-13. E = -385.374967 Ha +C 3.29819 -0.42589 0.47644 +C 1.96661 0.28250 0.43635 +O 0.99027 -0.63745 0.00662 +C -0.30605 -0.26545 0.01369 +C -0.75949 1.00486 0.38766 +C -2.12847 1.28036 0.37981 +C -3.05065 0.31101 0.00097 +C -2.59090 -0.95256 -0.38083 +C -1.23332 -1.24208 -0.37776 +H 3.26117 -1.28116 1.16618 +H 3.57195 -0.80368 -0.51960 +H 4.08414 0.26316 0.81790 +H 1.70464 0.67388 1.43741 +H 1.99826 1.14932 -0.25076 +H -0.05800 1.78183 0.68997 +H -2.47360 2.27145 0.68464 +H -4.11920 0.53742 0.00059 +H -3.30062 -1.72558 -0.68466 +H -0.85494 -2.22194 -0.67544 diff --git a/autodE/source/autode/solvent/lib/fluorobenzene.xyz b/autodE/source/autode/solvent/lib/fluorobenzene.xyz new file mode 100644 index 0000000000000000000000000000000000000000..074c647f43daf33ac92d389b0d9557dfd56166d4 --- /dev/null +++ b/autodE/source/autode/solvent/lib/fluorobenzene.xyz @@ -0,0 +1,14 @@ +12 +Generated by autodE on: 2021-10-13. E = -330.883402 Ha +F 2.49183 -0.98888 0.04050 +C 1.25498 -0.49717 0.01858 +C 1.07572 0.87932 -0.03873 +C -0.22293 1.38449 -0.05602 +C -1.31793 0.52203 -0.01799 +C -1.11155 -0.85625 0.03623 +C 0.18085 -1.37659 0.05424 +H 1.95176 1.52946 -0.07026 +H -0.38043 2.46494 -0.10191 +H -2.33218 0.92655 -0.03064 +H -1.96456 -1.53750 0.06750 +H 0.37444 -2.45040 0.09849 diff --git a/autodE/source/autode/solvent/lib/formamide.xyz b/autodE/source/autode/solvent/lib/formamide.xyz new file mode 100644 index 0000000000000000000000000000000000000000..c0d9b7afa135967bf2f9707ef2352799f7114598 --- /dev/null +++ b/autodE/source/autode/solvent/lib/formamide.xyz @@ -0,0 +1,8 @@ +6 +Generated by autodE on: 2021-10-13. E = -169.582046 Ha +O 1.42753 -0.70062 -0.54456 +C 0.65406 0.08325 -0.05290 +N -0.68071 -0.10863 0.07610 +H 0.97769 1.06969 0.36387 +H -1.28095 0.60669 0.45989 +H -1.09763 -0.95048 -0.30240 diff --git a/autodE/source/autode/solvent/lib/formic acid.xyz b/autodE/source/autode/solvent/lib/formic acid.xyz new file mode 100644 index 0000000000000000000000000000000000000000..a6123b4d7b2009ac790096731c2881508df46b6a --- /dev/null +++ b/autodE/source/autode/solvent/lib/formic acid.xyz @@ -0,0 +1,7 @@ +5 +Generated by autodE on: 2021-10-13. E = -189.417463 Ha +O -0.68801 1.14716 -0.03054 +C -0.41039 -0.01305 0.05773 +O 0.81569 -0.52245 -0.05134 +H -1.13814 -0.82980 0.24599 +H 1.42085 0.21814 -0.22174 diff --git a/autodE/source/autode/solvent/lib/heptane.xyz b/autodE/source/autode/solvent/lib/heptane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..33d267c0183c4de7d13e8e560553d408827283ef --- /dev/null +++ b/autodE/source/autode/solvent/lib/heptane.xyz @@ -0,0 +1,25 @@ +23 +Generated by autodE on: 2021-10-13. E = -275.865929 Ha +C 3.62662 0.09109 -0.74074 +C 2.28536 -0.59637 -0.53126 +C 1.20928 0.32343 0.03024 +C -0.15431 -0.34034 0.16418 +C -1.24168 0.59226 0.68537 +C -2.65428 0.01607 0.61650 +C -3.20483 -0.09916 -0.79740 +H 4.37223 -0.59274 -1.17336 +H 4.03468 0.47234 0.20970 +H 3.53297 0.95335 -1.42029 +H 1.92951 -1.01802 -1.48853 +H 2.41079 -1.46232 0.14206 +H 1.53211 0.71691 1.01202 +H 1.11454 1.20941 -0.62316 +H -0.44710 -0.74301 -0.82168 +H -0.07667 -1.21979 0.82839 +H -1.00449 0.87161 1.72698 +H -1.21570 1.53519 0.11047 +H -2.66854 -0.97490 1.10431 +H -3.32987 0.64878 1.21497 +H -2.56877 -0.73284 -1.43371 +H -4.21314 -0.54027 -0.80409 +H -3.26872 0.88972 -1.27986 diff --git a/autodE/source/autode/solvent/lib/hexane.xyz b/autodE/source/autode/solvent/lib/hexane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..b2b9d6a73b29dabbd14d83e6d1203ad53f7b41f8 --- /dev/null +++ b/autodE/source/autode/solvent/lib/hexane.xyz @@ -0,0 +1,22 @@ +20 +Generated by autodE on: 2021-10-13. E = -236.626249 Ha +C -2.99897 0.52805 0.19776 +C -1.58973 0.20378 0.67642 +C -0.75484 -0.52598 -0.37087 +C 0.68384 -0.82288 0.04507 +C 1.59875 0.39758 0.08056 +C 3.04466 0.05140 0.39973 +H -2.98077 1.20051 -0.67518 +H -3.53611 -0.38418 -0.10959 +H -3.59660 1.01915 0.98105 +H -1.63808 -0.41731 1.58823 +H -1.08214 1.13519 0.98150 +H -1.26199 -1.47248 -0.62869 +H -0.74398 0.06557 -1.30414 +H 1.11614 -1.56125 -0.65245 +H 0.68842 -1.31539 1.03504 +H 1.22317 1.12906 0.81638 +H 1.55092 0.90680 -0.89827 +H 3.45115 -0.67116 -0.32555 +H 3.69044 0.94235 0.37986 +H 3.13592 -0.40899 1.39752 diff --git a/autodE/source/autode/solvent/lib/hexanoic acid.xyz b/autodE/source/autode/solvent/lib/hexanoic acid.xyz new file mode 100644 index 0000000000000000000000000000000000000000..9e2c5a4f2e4e090187694e4323d31aa4d2744371 --- /dev/null +++ b/autodE/source/autode/solvent/lib/hexanoic acid.xyz @@ -0,0 +1,22 @@ +20 +Generated by autodE on: 2021-10-13. E = -385.627980 Ha +C 2.77212 0.95299 -0.10870 +C 1.84732 -0.23427 -0.31147 +C 0.39912 0.04592 0.06640 +C -0.52844 -1.12170 -0.25050 +C -1.95281 -1.01078 0.31012 +C -2.58395 0.32872 0.03882 +O -2.30731 1.21903 1.00676 +O -3.24197 0.61556 -0.92673 +H 3.81094 0.70122 -0.37225 +H 2.46700 1.80737 -0.73307 +H 2.76615 1.29653 0.93874 +H 2.21316 -1.09476 0.27546 +H 1.89030 -0.55661 -1.36721 +H 0.05145 0.94522 -0.47142 +H 0.33531 0.30535 1.13782 +H -0.58486 -1.24409 -1.34538 +H -0.08562 -2.05437 0.13703 +H -2.59132 -1.77137 -0.15937 +H -1.95217 -1.18224 1.39728 +H -2.72441 2.05230 0.73753 diff --git a/autodE/source/autode/solvent/lib/iodobenzene.xyz b/autodE/source/autode/solvent/lib/iodobenzene.xyz new file mode 100644 index 0000000000000000000000000000000000000000..0fe5cd2e89829cacab01f807fbdf35c59cd6202a --- /dev/null +++ b/autodE/source/autode/solvent/lib/iodobenzene.xyz @@ -0,0 +1,14 @@ +12 +Generated by autodE on: 2021-10-13. E = -528.927094 Ha +I 3.37364 -0.46070 -0.00659 +C 1.28888 -0.17520 -0.00211 +C 0.77153 1.11993 -0.01461 +C -0.61100 1.29987 -0.01221 +C -1.46565 0.19969 0.00237 +C -0.93484 -1.08888 0.01503 +C 0.44527 -1.28556 0.01283 +H 1.44064 1.98184 -0.02705 +H -1.02155 2.31263 -0.02272 +H -2.54809 0.34855 0.00419 +H -1.59815 -1.95675 0.02753 +H 0.85922 -2.29552 0.02334 diff --git a/autodE/source/autode/solvent/lib/iodoethane.xyz b/autodE/source/autode/solvent/lib/iodoethane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..6a2bdc03f48d6a1adfa1393913dd7922b0d00b87 --- /dev/null +++ b/autodE/source/autode/solvent/lib/iodoethane.xyz @@ -0,0 +1,10 @@ +8 +Generated by autodE on: 2021-10-13. E = -376.783413 Ha +C -0.81519 0.10574 0.07596 +C 0.63952 -0.28882 0.04569 +I 1.87985 1.19926 -0.92127 +H -0.97086 1.04064 0.63310 +H -1.39842 -0.68555 0.57568 +H -1.22181 0.23929 -0.93685 +H 1.06868 -0.40007 1.04957 +H 0.81822 -1.21059 -0.52198 diff --git a/autodE/source/autode/solvent/lib/iodomethane.xyz b/autodE/source/autode/solvent/lib/iodomethane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..90bd51f0d67205add37b613d5c67294a1f3032f2 --- /dev/null +++ b/autodE/source/autode/solvent/lib/iodomethane.xyz @@ -0,0 +1,7 @@ +5 +Generated by autodE on: 2021-10-13. E = -337.541297 Ha +C -0.22273 0.01204 0.00782 +I 1.91175 -0.10402 -0.06599 +H -0.52800 -0.00021 1.05965 +H -0.62951 -0.85301 -0.52581 +H -0.53142 0.94520 -0.47578 diff --git a/autodE/source/autode/solvent/lib/isopropylbenzene.xyz b/autodE/source/autode/solvent/lib/isopropylbenzene.xyz new file mode 100644 index 0000000000000000000000000000000000000000..ecdbd912a5fb8512a94db1b04aaec30e6f7b7d1b --- /dev/null +++ b/autodE/source/autode/solvent/lib/isopropylbenzene.xyz @@ -0,0 +1,23 @@ +21 +Generated by autodE on: 2021-10-13. E = -349.544878 Ha +C 3.11604 0.61142 -0.33243 +C 1.70517 0.11632 -0.19187 +C 1.39017 -1.24025 -0.29240 +C 0.06948 -1.67295 -0.18691 +C -0.97812 -0.77672 0.02798 +C -2.39111 -1.26084 0.16406 +C -0.67490 0.59560 0.13882 +C -1.76521 1.59325 0.39218 +C 0.65243 1.01231 0.02279 +H 3.84493 -0.19666 -0.17670 +H 3.33800 1.41043 0.39001 +H 3.29247 1.03154 -1.33753 +H 2.18601 -1.97096 -0.46167 +H -0.15533 -2.74026 -0.27415 +H -2.47331 -2.32944 -0.08015 +H -3.07933 -0.71140 -0.49609 +H -2.76702 -1.12037 1.19221 +H -1.36810 2.61759 0.41877 +H -2.54830 1.55005 -0.38219 +H -2.26964 1.40068 1.35436 +H 0.87547 2.08076 0.11103 diff --git a/autodE/source/autode/solvent/lib/isoquinoline.xyz b/autodE/source/autode/solvent/lib/isoquinoline.xyz new file mode 100644 index 0000000000000000000000000000000000000000..958d405b276630861813dbab7d78d3ae240a8c5f --- /dev/null +++ b/autodE/source/autode/solvent/lib/isoquinoline.xyz @@ -0,0 +1,19 @@ +17 +Generated by autodE on: 2021-10-13. E = -401.187105 Ha +C 0.17368 -0.66554 0.06164 +C 0.20447 0.75344 -0.05830 +C 1.47463 1.38914 -0.03257 +N 2.61160 0.75471 0.09579 +C 2.57663 -0.59393 0.20312 +C 1.41641 -1.33173 0.19123 +C -1.00839 1.47145 -0.18928 +C -2.21088 0.80460 -0.19418 +C -2.24406 -0.60528 -0.07281 +C -1.07960 -1.32743 0.04811 +H 1.51915 2.48288 -0.12759 +H 3.54629 -1.09256 0.30782 +H 1.44542 -2.42006 0.28888 +H -0.96877 2.55952 -0.29108 +H -3.14554 1.36002 -0.29727 +H -3.20635 -1.12295 -0.07713 +H -1.10469 -2.41617 0.14354 diff --git a/autodE/source/autode/solvent/lib/krypton.xyz b/autodE/source/autode/solvent/lib/krypton.xyz new file mode 100644 index 0000000000000000000000000000000000000000..35dde98bb2b31ccdfff60e955901d71bb9957433 --- /dev/null +++ b/autodE/source/autode/solvent/lib/krypton.xyz @@ -0,0 +1,3 @@ +1 +Generated by autodE on: 2021-10-13. E = -2753.121293 Ha +Kr 0.00100 0.00100 0.00100 diff --git a/autodE/source/autode/solvent/lib/m-cresol.xyz b/autodE/source/autode/solvent/lib/m-cresol.xyz new file mode 100644 index 0000000000000000000000000000000000000000..68e0c04c560346a61bac0399947ea75fbb1dfd36 --- /dev/null +++ b/autodE/source/autode/solvent/lib/m-cresol.xyz @@ -0,0 +1,18 @@ +16 +Generated by autodE on: 2021-10-13. E = -346.149543 Ha +C 2.27843 -0.49022 -0.18753 +C 0.85260 -0.03754 -0.05152 +C -0.18556 -0.97652 -0.01585 +C -1.51985 -0.57451 0.08478 +O -2.54507 -1.45328 0.11515 +C -1.83130 0.78745 0.15738 +C -0.80482 1.72135 0.12535 +C 0.52815 1.32016 0.02179 +H 2.48135 -0.86208 -1.20608 +H 2.51190 -1.31160 0.50584 +H 2.98015 0.33090 0.01465 +H 0.05113 -2.04553 -0.07161 +H -2.19271 -2.34883 0.07908 +H -2.87723 1.08632 0.24586 +H -1.05078 2.78483 0.18671 +H 1.32360 2.06900 -0.00391 diff --git a/autodE/source/autode/solvent/lib/m-xylene.xyz b/autodE/source/autode/solvent/lib/m-xylene.xyz new file mode 100644 index 0000000000000000000000000000000000000000..4f9ffbc0f18280f5a1af80cc7ddf33672638501a --- /dev/null +++ b/autodE/source/autode/solvent/lib/m-xylene.xyz @@ -0,0 +1,20 @@ +18 +Generated by autodE on: 2021-10-13. E = -310.296181 Ha +C -2.47254 0.05823 0.61012 +C -1.62946 -0.12628 -0.64914 +C -0.15019 -0.06102 -0.37724 +C 0.51444 -1.15219 0.19759 +C 1.87355 -1.08808 0.49684 +C 2.59340 0.07635 0.22778 +C 1.94700 1.16935 -0.34555 +C 0.58600 1.09897 -0.64310 +H -2.27037 1.03195 1.08403 +H -3.54738 0.00557 0.37640 +H -2.24850 -0.71738 1.35831 +H -1.87423 -1.09690 -1.11250 +H -1.89826 0.64528 -1.38770 +H -0.04803 -2.06531 0.41648 +H 2.37539 -1.95037 0.94331 +H 3.65801 0.13080 0.46810 +H 2.50618 2.08161 -0.56802 +H 0.08489 1.95952 -1.09560 diff --git a/autodE/source/autode/solvent/lib/mesitylene.xyz b/autodE/source/autode/solvent/lib/mesitylene.xyz new file mode 100644 index 0000000000000000000000000000000000000000..ecdbd912a5fb8512a94db1b04aaec30e6f7b7d1b --- /dev/null +++ b/autodE/source/autode/solvent/lib/mesitylene.xyz @@ -0,0 +1,23 @@ +21 +Generated by autodE on: 2021-10-13. E = -349.544878 Ha +C 3.11604 0.61142 -0.33243 +C 1.70517 0.11632 -0.19187 +C 1.39017 -1.24025 -0.29240 +C 0.06948 -1.67295 -0.18691 +C -0.97812 -0.77672 0.02798 +C -2.39111 -1.26084 0.16406 +C -0.67490 0.59560 0.13882 +C -1.76521 1.59325 0.39218 +C 0.65243 1.01231 0.02279 +H 3.84493 -0.19666 -0.17670 +H 3.33800 1.41043 0.39001 +H 3.29247 1.03154 -1.33753 +H 2.18601 -1.97096 -0.46167 +H -0.15533 -2.74026 -0.27415 +H -2.47331 -2.32944 -0.08015 +H -3.07933 -0.71140 -0.49609 +H -2.76702 -1.12037 1.19221 +H -1.36810 2.61759 0.41877 +H -2.54830 1.55005 -0.38219 +H -2.26964 1.40068 1.35436 +H 0.87547 2.08076 0.11103 diff --git a/autodE/source/autode/solvent/lib/methanol.xyz b/autodE/source/autode/solvent/lib/methanol.xyz new file mode 100644 index 0000000000000000000000000000000000000000..6efec2709eebb601257d97e618198fdc04ce64bc --- /dev/null +++ b/autodE/source/autode/solvent/lib/methanol.xyz @@ -0,0 +1,8 @@ +6 +Generated by autodE on: 2021-10-13. E = -115.500297 Ha +C -0.34238 -0.01780 0.00938 +O 0.95562 -0.10920 0.51925 +H -1.04116 -0.19943 0.83928 +H -0.58197 0.97927 -0.41199 +H -0.56101 -0.77141 -0.77184 +H 1.57090 0.11847 -0.18408 diff --git a/autodE/source/autode/solvent/lib/methyl benzoate.xyz b/autodE/source/autode/solvent/lib/methyl benzoate.xyz new file mode 100644 index 0000000000000000000000000000000000000000..319ce0ef345b640d7fbae1e8a6f6ee226320f96a --- /dev/null +++ b/autodE/source/autode/solvent/lib/methyl benzoate.xyz @@ -0,0 +1,20 @@ +18 +Generated by autodE on: 2021-10-13. E = -459.286772 Ha +O 1.55775 -1.76088 -0.37274 +C 1.20226 -0.62785 -0.16210 +O 2.05841 0.38689 0.00653 +C 3.43135 0.04924 -0.06398 +C -0.21802 -0.20435 -0.04961 +C -0.59418 1.13067 0.13262 +C -1.94217 1.46297 0.23583 +C -2.91466 0.46602 0.16539 +C -2.54065 -0.86619 -0.01428 +C -1.19580 -1.20083 -0.12504 +H 3.98777 0.96784 0.15575 +H 3.68151 -0.73080 0.66892 +H 3.69275 -0.32689 -1.06470 +H 0.17720 1.89951 0.19476 +H -2.23983 2.50411 0.38145 +H -3.97156 0.73048 0.25450 +H -3.30207 -1.64718 -0.07064 +H -0.87017 -2.23266 -0.27275 diff --git a/autodE/source/autode/solvent/lib/methyl butanoate.xyz b/autodE/source/autode/solvent/lib/methyl butanoate.xyz new file mode 100644 index 0000000000000000000000000000000000000000..f260296cc372818fdcdf0fb30a4004f37c7a7b51 --- /dev/null +++ b/autodE/source/autode/solvent/lib/methyl butanoate.xyz @@ -0,0 +1,19 @@ +17 +Generated by autodE on: 2021-10-13. E = -346.374391 Ha +C -1.93200 0.98624 0.18417 +C -1.44389 -0.02035 -0.84582 +C -0.64221 -1.17018 -0.24377 +C 0.72062 -0.80555 0.28785 +O 1.35183 0.06126 -0.51975 +C 2.66659 0.42635 -0.14126 +O 1.22119 -1.25213 1.28568 +H -1.09237 1.45974 0.71891 +H -2.51510 1.79040 -0.29080 +H -2.57627 0.50678 0.93894 +H -0.82311 0.48596 -1.60161 +H -2.30607 -0.44549 -1.38522 +H -1.18369 -1.66108 0.57867 +H -0.46717 -1.94171 -1.01312 +H 3.35175 -0.43020 -0.23308 +H 2.69418 0.78279 0.89931 +H 2.97574 1.22707 -0.82264 diff --git a/autodE/source/autode/solvent/lib/methyl ethanoate.xyz b/autodE/source/autode/solvent/lib/methyl ethanoate.xyz new file mode 100644 index 0000000000000000000000000000000000000000..0c22438a110b997768d3f64f24ad580dfe040c98 --- /dev/null +++ b/autodE/source/autode/solvent/lib/methyl ethanoate.xyz @@ -0,0 +1,13 @@ +11 +Generated by autodE on: 2021-10-13. E = -267.894126 Ha +C 1.81910 0.19377 -0.02983 +C 0.43892 -0.39423 -0.00409 +O -0.49684 0.56589 0.02681 +C -1.84041 0.11965 0.04218 +O 0.17515 -1.56774 -0.02328 +H 2.00968 0.63287 -1.02157 +H 1.91399 1.00335 0.70581 +H 2.55384 -0.59495 0.16545 +H -2.09714 -0.39383 -0.89739 +H -2.01512 -0.58002 0.87198 +H -2.46098 1.01523 0.16392 diff --git a/autodE/source/autode/solvent/lib/methyl methanoate.xyz b/autodE/source/autode/solvent/lib/methyl methanoate.xyz new file mode 100644 index 0000000000000000000000000000000000000000..557c38c2b94b6f6f2aeb230c5a4d95b71be94961 --- /dev/null +++ b/autodE/source/autode/solvent/lib/methyl methanoate.xyz @@ -0,0 +1,10 @@ +8 +Generated by autodE on: 2021-10-13. E = -228.642439 Ha +O 1.35776 1.12496 0.12999 +C 1.28764 0.03559 -0.36051 +O 0.17886 -0.69098 -0.47921 +C -1.00999 -0.09507 0.02159 +H 2.14367 -0.52856 -0.78451 +H -1.76787 -0.88649 0.03942 +H -0.85211 0.31526 1.03036 +H -1.33796 0.72528 -0.63314 diff --git a/autodE/source/autode/solvent/lib/methyl propanoate.xyz b/autodE/source/autode/solvent/lib/methyl propanoate.xyz new file mode 100644 index 0000000000000000000000000000000000000000..3920434ddb7b3ab0c4de46715eb51d0c613a08ee --- /dev/null +++ b/autodE/source/autode/solvent/lib/methyl propanoate.xyz @@ -0,0 +1,16 @@ +14 +Generated by autodE on: 2021-10-13. E = -307.132447 Ha +C -1.91412 0.36889 -0.50231 +C -1.15543 0.04721 0.78494 +C 0.20785 -0.53661 0.52028 +O 1.02071 0.38942 -0.01951 +C 2.33528 0.00179 -0.37709 +O 0.52937 -1.67581 0.72930 +H -2.84433 0.90882 -0.27510 +H -1.30509 1.00248 -1.16353 +H -2.17418 -0.54892 -1.05055 +H -1.02144 0.97337 1.36587 +H -1.70784 -0.67363 1.40260 +H 2.48254 0.19680 -1.44976 +H 2.49494 -1.06278 -0.15810 +H 3.05165 0.60887 0.19315 diff --git a/autodE/source/autode/solvent/lib/methylcyclohexane.xyz b/autodE/source/autode/solvent/lib/methylcyclohexane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..5834a3f64e6de656c1a0e6f1b49ebcf9175f8c36 --- /dev/null +++ b/autodE/source/autode/solvent/lib/methylcyclohexane.xyz @@ -0,0 +1,23 @@ +21 +Generated by autodE on: 2021-10-13. E = -274.668771 Ha +C 2.27746 -0.81522 0.15229 +C 0.82546 -0.40414 0.33404 +C 0.66147 1.12451 0.27010 +C -0.76244 1.56021 -0.10795 +C -1.80168 0.54978 0.36019 +C -1.57909 -0.83261 -0.27783 +C -0.11613 -1.05545 -0.67624 +H 2.42494 -1.89016 0.34293 +H 2.61726 -0.60474 -0.87586 +H 2.93757 -0.26046 0.83628 +H 0.50402 -0.74467 1.33670 +H 0.95770 1.55835 1.23988 +H 1.37391 1.53142 -0.46818 +H -0.84061 1.66669 -1.20331 +H -0.98260 2.55586 0.30839 +H -1.74245 0.47082 1.45976 +H -2.81701 0.91498 0.13758 +H -2.22568 -0.95743 -1.16095 +H -1.88863 -1.61467 0.43464 +H 0.09189 -2.13352 -0.77105 +H 0.08454 -0.61943 -1.67163 diff --git a/autodE/source/autode/solvent/lib/n,n-dimethylacetamide.xyz b/autodE/source/autode/solvent/lib/n,n-dimethylacetamide.xyz new file mode 100644 index 0000000000000000000000000000000000000000..7217494a7f10834aa000d58d809b143073a76433 --- /dev/null +++ b/autodE/source/autode/solvent/lib/n,n-dimethylacetamide.xyz @@ -0,0 +1,17 @@ +15 +Generated by autodE on: 2021-10-13. E = -287.296717 Ha +C 1.93793 -0.55220 0.07544 +C 0.86275 0.18415 -0.69410 +N -0.39822 0.21014 -0.15328 +C -1.42242 0.94915 -0.84767 +C -0.82937 -0.53697 0.99723 +O 1.11905 0.74320 -1.74467 +H 1.77116 -1.64090 0.06477 +H 2.89063 -0.33875 -0.42200 +H 1.99757 -0.22698 1.12472 +H -0.94277 1.52369 -1.64849 +H -2.17552 0.27599 -1.29441 +H -1.94154 1.63368 -0.15538 +H -1.30959 0.12631 1.73872 +H 0.00973 -1.04171 1.48795 +H -1.56940 -1.30871 0.71688 diff --git a/autodE/source/autode/solvent/lib/n-butylbenzene.xyz b/autodE/source/autode/solvent/lib/n-butylbenzene.xyz new file mode 100644 index 0000000000000000000000000000000000000000..96004ec7bf3b3cc226d85bea2857d1fe12c13842 --- /dev/null +++ b/autodE/source/autode/solvent/lib/n-butylbenzene.xyz @@ -0,0 +1,26 @@ +24 +Generated by autodE on: 2021-10-13. E = -388.780568 Ha +C 3.78829 0.07206 -0.69926 +C 2.31249 -0.01456 -0.44567 +C 1.65329 0.91907 0.36029 +C 0.28914 0.81174 0.62509 +C -0.46726 -0.23676 0.09264 +C -1.95645 -0.36411 0.33274 +C -2.73986 0.38866 -0.74234 +C -2.37768 0.05535 1.73837 +C 0.19182 -1.16445 -0.72326 +C 1.55256 -1.05703 -0.98898 +H 4.03589 -0.20022 -1.73665 +H 4.34643 -0.61759 -0.04620 +H 4.17156 1.08427 -0.50524 +H 2.21995 1.74655 0.79647 +H -0.18736 1.55972 1.26322 +H -2.20090 -1.43503 0.21860 +H -3.82640 0.26375 -0.60264 +H -2.50994 1.46678 -0.71336 +H -2.47999 0.02075 -1.74672 +H -1.76385 -0.44285 2.50256 +H -2.28262 1.14292 1.88530 +H -3.43088 -0.20777 1.92177 +H -0.37711 -1.99044 -1.15954 +H 2.03896 -1.80061 -1.62708 diff --git a/autodE/source/autode/solvent/lib/n-dodecane.xyz b/autodE/source/autode/solvent/lib/n-dodecane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..211177f378ef4f2abe667f08895506a38661727d --- /dev/null +++ b/autodE/source/autode/solvent/lib/n-dodecane.xyz @@ -0,0 +1,40 @@ +38 +Generated by autodE on: 2021-10-13. E = -472.059558 Ha +C 4.02383 1.48027 0.75433 +C 4.84742 0.49207 -0.05859 +C 4.16833 -0.83237 -0.40904 +C 3.08962 -0.81676 -1.49431 +C 1.74412 -0.15051 -1.19218 +C 1.09529 -0.54689 0.13027 +C -0.30178 0.02712 0.35880 +C -1.37108 -0.48038 -0.60135 +C -2.76609 0.10817 -0.39941 +C -3.35659 -0.10338 0.98944 +C -4.74347 0.49713 1.19952 +C -5.86232 -0.09154 0.35542 +H 4.62759 2.35526 1.03970 +H 3.65567 1.02081 1.68558 +H 3.14939 1.85283 0.20103 +H 5.19722 0.97790 -0.98716 +H 5.76426 0.25940 0.51018 +H 4.95443 -1.52423 -0.75577 +H 3.76578 -1.29135 0.51163 +H 2.88914 -1.86737 -1.77023 +H 3.51301 -0.35019 -2.40178 +H 1.06390 -0.40655 -2.02190 +H 1.84073 0.94896 -1.22491 +H 1.05035 -1.64967 0.20433 +H 1.74038 -0.21961 0.96156 +H -0.25825 1.13109 0.30366 +H -0.60203 -0.21303 1.39259 +H -1.42642 -1.58168 -0.51651 +H -1.05995 -0.27082 -1.63892 +H -3.43583 -0.33226 -1.15636 +H -2.74189 1.19231 -0.61750 +H -3.38364 -1.18609 1.21538 +H -2.68223 0.33999 1.73979 +H -5.00959 0.38065 2.26469 +H -4.68900 1.58619 1.01767 +H -5.94619 -1.18077 0.50504 +H -5.71396 0.08807 -0.72148 +H -6.83026 0.35718 0.62702 diff --git a/autodE/source/autode/solvent/lib/n-hexadecane.xyz b/autodE/source/autode/solvent/lib/n-hexadecane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..00f1f9d9781b66a29cdfe6c153215225b5ca6777 --- /dev/null +++ b/autodE/source/autode/solvent/lib/n-hexadecane.xyz @@ -0,0 +1,52 @@ +50 +Generated by autodE on: 2021-10-13. E = -629.019097 Ha +C -6.64776 0.99178 -0.70865 +C -5.82989 0.13712 -1.66963 +C -5.03412 -1.01115 -1.04910 +C -3.74392 -0.65346 -0.31219 +C -3.88874 0.01382 1.05680 +C -2.60223 -0.00766 1.88037 +C -1.46811 0.82330 1.28689 +C -0.09023 0.58766 1.90184 +C 0.45152 -0.83439 1.75859 +C 0.66082 -1.32563 0.32753 +C 1.70003 -0.56794 -0.49727 +C 3.08894 -0.52029 0.12837 +C 4.18714 -0.08137 -0.83337 +C 5.57206 -0.07766 -0.19642 +C 6.72967 0.17835 -1.15935 +C 6.82815 1.60504 -1.68361 +H -7.31936 0.36865 -0.09508 +H -6.01953 1.57130 -0.01658 +H -7.27544 1.70966 -1.26004 +H -5.14353 0.78249 -2.24841 +H -6.52006 -0.29714 -2.41382 +H -4.76120 -1.70611 -1.86069 +H -5.69719 -1.58853 -0.37771 +H -3.12312 -0.02542 -0.97474 +H -3.16673 -1.58551 -0.17224 +H -4.68147 -0.50646 1.62096 +H -4.23138 1.05627 0.94559 +H -2.81290 0.34875 2.90370 +H -2.28123 -1.05841 1.99013 +H -1.40781 0.63895 0.20058 +H -1.72594 1.89266 1.38996 +H 0.62032 1.29873 1.44717 +H -0.11901 0.84772 2.97485 +H 1.40308 -0.90657 2.31071 +H -0.22724 -1.53631 2.27097 +H 0.96585 -2.38594 0.37488 +H -0.30149 -1.31773 -0.21391 +H 1.77231 -1.05525 -1.48525 +H 1.35380 0.46058 -0.70382 +H 3.34299 -1.52184 0.52405 +H 3.08276 0.14917 1.00596 +H 3.94412 0.91493 -1.24449 +H 4.19569 -0.76429 -1.70221 +H 5.60190 0.66374 0.62331 +H 5.72829 -1.05695 0.28857 +H 7.67312 -0.08335 -0.64967 +H 6.64596 -0.52207 -2.00916 +H 7.65243 1.71680 -2.40491 +H 7.01326 2.31641 -0.86311 +H 5.90543 1.92307 -2.19506 diff --git a/autodE/source/autode/solvent/lib/n-methylaniline.xyz b/autodE/source/autode/solvent/lib/n-methylaniline.xyz new file mode 100644 index 0000000000000000000000000000000000000000..a0ca84e7f097f8d6dbb463357cadd6490173eaa4 --- /dev/null +++ b/autodE/source/autode/solvent/lib/n-methylaniline.xyz @@ -0,0 +1,19 @@ +17 +Generated by autodE on: 2021-10-13. E = -326.305754 Ha +C 2.64780 -0.28373 0.03046 +N 1.57567 0.60408 -0.29456 +C 0.24895 0.30619 -0.12743 +C -0.73554 1.21521 -0.56776 +C -2.08500 0.93503 -0.40843 +C -2.50360 -0.25372 0.19506 +C -1.53720 -1.15323 0.63823 +C -0.17818 -0.88924 0.48245 +H 2.62829 -1.21787 -0.56166 +H 3.60311 0.22172 -0.16671 +H 2.64162 -0.57304 1.09745 +H 1.79403 1.45776 -0.78343 +H -0.42025 2.14960 -1.04231 +H -2.82230 1.65840 -0.76508 +H -3.56571 -0.47652 0.31847 +H -1.84596 -2.08635 1.11706 +H 0.55417 -1.61438 0.83851 diff --git a/autodE/source/autode/solvent/lib/n-methylformamide.xyz b/autodE/source/autode/solvent/lib/n-methylformamide.xyz new file mode 100644 index 0000000000000000000000000000000000000000..0bad29394c147cedc39503e487e12b4dd045c751 --- /dev/null +++ b/autodE/source/autode/solvent/lib/n-methylformamide.xyz @@ -0,0 +1,11 @@ +9 +Generated by autodE on: 2021-10-13. E = -208.816876 Ha +O 1.22869 -1.56817 -0.02066 +C 1.30489 -0.36160 -0.04426 +N 0.25254 0.49210 -0.03428 +C -1.11219 0.04312 0.04028 +H 2.27945 0.18525 -0.08174 +H 0.45192 1.48391 -0.03905 +H -1.09847 -1.05337 -0.01744 +H -1.59064 0.34063 0.98851 +H -1.71610 0.43803 -0.79136 diff --git a/autodE/source/autode/solvent/lib/n-nonane.xyz b/autodE/source/autode/solvent/lib/n-nonane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..be202791c0e85db87a7f903f18b5327a9a7bed6e --- /dev/null +++ b/autodE/source/autode/solvent/lib/n-nonane.xyz @@ -0,0 +1,31 @@ +29 +Generated by autodE on: 2021-10-13. E = -354.343549 Ha +C -4.52048 0.25567 -0.13139 +C -3.18483 -0.44859 0.05513 +C -2.09621 0.44262 0.64644 +C -0.71065 -0.20189 0.65846 +C -0.07675 -0.27800 -0.72557 +C 1.16406 -1.15479 -0.84402 +C 2.40038 -0.72208 -0.06049 +C 2.84146 0.71737 -0.30784 +C 4.26476 0.99878 0.15128 +H -4.91903 0.62780 0.82661 +H -4.42304 1.12519 -0.80099 +H -5.27515 -0.41560 -0.56840 +H -2.84837 -0.84771 -0.91722 +H -3.31482 -1.33208 0.70431 +H -2.04794 1.38680 0.07466 +H -2.38855 0.73076 1.67133 +H -0.78608 -1.21820 1.08736 +H -0.04816 0.36087 1.33663 +H 0.15198 0.74535 -1.07388 +H -0.82208 -0.66612 -1.43887 +H 1.43977 -1.20734 -1.91246 +H 0.89695 -2.18512 -0.54781 +H 2.24529 -0.87383 1.02330 +H 3.22602 -1.40170 -0.33542 +H 2.14558 1.40823 0.19783 +H 2.75644 0.94767 -1.38519 +H 4.53911 2.05499 0.00318 +H 4.98904 0.38936 -0.41109 +H 4.40131 0.76160 1.21952 diff --git a/autodE/source/autode/solvent/lib/n-octane.xyz b/autodE/source/autode/solvent/lib/n-octane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..3e63737b821a244e0729372783a08cfafdbe0dfe --- /dev/null +++ b/autodE/source/autode/solvent/lib/n-octane.xyz @@ -0,0 +1,28 @@ +26 +Generated by autodE on: 2021-10-13. E = -315.106844 Ha +C -1.25470 -1.03264 1.11624 +C -1.20131 -0.02335 -0.03073 +C -1.41153 -0.74001 -1.36779 +C -2.33502 0.98736 0.15588 +C 0.13486 0.74483 -0.04780 +C 1.43143 -0.08041 -0.14036 +C 2.03175 -0.40237 1.22827 +C 2.46718 0.63402 -1.00390 +H -1.05979 -0.53863 2.08219 +H -2.24901 -1.50352 1.17629 +H -0.51973 -1.84111 0.98816 +H -2.38568 -1.25452 -1.38850 +H -1.39290 -0.02218 -2.20429 +H -0.63646 -1.49729 -1.55914 +H -3.31460 0.48320 0.11508 +H -2.31942 1.75967 -0.62963 +H -2.26078 1.50000 1.12866 +H 0.18457 1.39001 0.84899 +H 0.08918 1.43717 -0.90729 +H 1.19456 -1.03779 -0.63984 +H 2.34188 0.52413 1.73997 +H 2.92665 -1.03686 1.12943 +H 1.32468 -0.92587 1.88632 +H 2.74194 1.60508 -0.55918 +H 3.38950 0.04037 -1.10005 +H 2.08266 0.83051 -2.01700 diff --git a/autodE/source/autode/solvent/lib/n-pentadecane.xyz b/autodE/source/autode/solvent/lib/n-pentadecane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..1829e1791ce70e39005faca78ee8b398868744c5 --- /dev/null +++ b/autodE/source/autode/solvent/lib/n-pentadecane.xyz @@ -0,0 +1,49 @@ +47 +Generated by autodE on: 2021-10-13. E = -589.778860 Ha +C -6.18197 0.04592 0.87176 +C -5.68851 -1.22729 0.19385 +C -4.18685 -1.32344 -0.08143 +C -3.57627 -0.35970 -1.10183 +C -3.45641 1.11593 -0.70806 +C -2.83760 1.38721 0.66360 +C -1.42473 0.84507 0.85516 +C -0.34760 1.49906 -0.00439 +C 0.89314 0.63191 -0.16404 +C 2.04233 1.33434 -0.87520 +C 3.19728 0.41810 -1.26189 +C 3.91669 -0.22960 -0.08365 +C 5.05382 -1.14392 -0.51617 +C 5.81912 -1.80373 0.62603 +C 6.61741 -0.83995 1.49485 +H -6.13776 0.91820 0.20557 +H -7.22681 -0.06100 1.20187 +H -5.58435 0.28532 1.76505 +H -6.23966 -1.37800 -0.75146 +H -5.96390 -2.08590 0.82976 +H -3.64389 -1.24366 0.87636 +H -3.98571 -2.34658 -0.44349 +H -4.15010 -0.42444 -2.04267 +H -2.57066 -0.73968 -1.35376 +H -2.86550 1.62889 -1.48572 +H -4.44588 1.59724 -0.74443 +H -3.48649 0.95777 1.44322 +H -2.84283 2.47605 0.84838 +H -1.13987 0.95105 1.91690 +H -1.42666 -0.24209 0.65820 +H -0.74423 1.72870 -1.00852 +H -0.07017 2.47464 0.43254 +H 1.22249 0.27053 0.82622 +H 0.61683 -0.27659 -0.73136 +H 2.42052 2.15278 -0.23552 +H 1.64936 1.82364 -1.78345 +H 3.93253 0.99105 -1.85374 +H 2.82495 -0.37424 -1.93698 +H 3.20191 -0.81584 0.52116 +H 4.29609 0.56472 0.58396 +H 5.76305 -0.57197 -1.14288 +H 4.64002 -1.92911 -1.17346 +H 5.11252 -2.37075 1.25938 +H 6.50609 -2.55469 0.20000 +H 7.20959 -1.38017 2.24975 +H 5.96965 -0.13124 2.03484 +H 7.31880 -0.24456 0.88689 diff --git a/autodE/source/autode/solvent/lib/n-undecane.xyz b/autodE/source/autode/solvent/lib/n-undecane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..277762ba070d603b4cc584ca6042fa3c5ea20db4 --- /dev/null +++ b/autodE/source/autode/solvent/lib/n-undecane.xyz @@ -0,0 +1,37 @@ +35 +Generated by autodE on: 2021-10-13. E = -432.824731 Ha +C -6.08535 0.30656 -0.67919 +C -4.66435 0.81749 -0.50422 +C -3.61117 -0.28363 -0.53934 +C -2.19959 0.22398 -0.29147 +C -1.11940 -0.84870 -0.32882 +C 0.26496 -0.29914 -0.01679 +C 1.39683 -1.30828 -0.16949 +C 2.75522 -0.83183 0.35089 +C 3.16719 0.56048 -0.11728 +C 4.48236 1.07029 0.46376 +C 5.73711 0.33814 0.01422 +H -6.20909 -0.21295 -1.64356 +H -6.81933 1.12588 -0.64138 +H -6.34932 -0.41416 0.11064 +H -4.58286 1.36364 0.45234 +H -4.43500 1.56081 -1.28822 +H -3.86019 -1.05250 0.21380 +H -3.65210 -0.80163 -1.51505 +H -2.16817 0.73621 0.68753 +H -1.96074 1.00385 -1.03672 +H -1.36800 -1.65195 0.38739 +H -1.11196 -1.32970 -1.32400 +H 0.45128 0.56641 -0.67428 +H 0.26491 0.10744 1.01180 +H 1.48517 -1.58607 -1.23541 +H 1.12367 -2.23935 0.35666 +H 3.52105 -1.57054 0.05837 +H 2.74282 -0.83668 1.45697 +H 2.37849 1.27630 0.16622 +H 3.21156 0.58725 -1.22259 +H 4.58350 2.13732 0.19961 +H 4.41435 1.03674 1.56672 +H 5.73265 -0.72117 0.31721 +H 5.84746 0.37382 -1.08207 +H 6.63605 0.79567 0.45477 diff --git a/autodE/source/autode/solvent/lib/nitrobenzene.xyz b/autodE/source/autode/solvent/lib/nitrobenzene.xyz new file mode 100644 index 0000000000000000000000000000000000000000..4d9db3cc8c904594abf568838a8611992855d6e8 --- /dev/null +++ b/autodE/source/autode/solvent/lib/nitrobenzene.xyz @@ -0,0 +1,16 @@ +14 +Generated by autodE on: 2021-10-13. E = -435.960226 Ha +O 2.97581 0.73630 -0.14555 +N 2.30055 -0.18257 0.26117 +C 0.84400 -0.07412 0.09193 +C 0.02714 -1.05911 0.63835 +C -1.35103 -0.93354 0.48480 +C -1.88342 0.15487 -0.20973 +C -1.04359 1.12566 -0.75693 +C 0.33536 1.01815 -0.60416 +O 2.72889 -1.17838 0.79893 +H 0.48254 -1.89532 1.17000 +H -2.01368 -1.68948 0.91088 +H -2.96548 0.24958 -0.32921 +H -1.46557 1.97227 -1.30277 +H 1.02840 1.75559 -1.00780 diff --git a/autodE/source/autode/solvent/lib/nitroethane.xyz b/autodE/source/autode/solvent/lib/nitroethane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..e8b72307852e59b449c3340fe2058e1a168e29d9 --- /dev/null +++ b/autodE/source/autode/solvent/lib/nitroethane.xyz @@ -0,0 +1,12 @@ +10 +Generated by autodE on: 2021-10-13. E = -283.812385 Ha +C -0.98711 0.02330 0.62342 +C 0.04774 -0.46965 -0.35603 +N 1.22582 0.45470 -0.45900 +O 1.63607 0.94803 0.56514 +O 1.69931 0.61564 -1.55886 +H -0.55797 0.11656 1.62956 +H -1.82478 -0.68767 0.65924 +H -1.38725 1.00188 0.32078 +H 0.47921 -1.43379 -0.04437 +H -0.33103 -0.56909 -1.37989 diff --git a/autodE/source/autode/solvent/lib/nitromethane.xyz b/autodE/source/autode/solvent/lib/nitromethane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..9ede5ed355cae195e17581b6d1ceced32bb10d72 --- /dev/null +++ b/autodE/source/autode/solvent/lib/nitromethane.xyz @@ -0,0 +1,9 @@ +7 +Generated by autodE on: 2021-10-13. E = -244.567354 Ha +C -0.64375 -0.01266 0.08362 +N 0.82643 0.02766 -0.13199 +O 1.41821 -1.01737 0.00249 +O 1.32049 1.09893 -0.39262 +H -0.80603 -0.13529 1.16199 +H -1.07585 0.92457 -0.27850 +H -1.03950 -0.88585 -0.44510 diff --git a/autodE/source/autode/solvent/lib/o-chlorotoluene.xyz b/autodE/source/autode/solvent/lib/o-chlorotoluene.xyz new file mode 100644 index 0000000000000000000000000000000000000000..df6cf910a8ecb3afed9bf7e09129a5e5d8ebffd4 --- /dev/null +++ b/autodE/source/autode/solvent/lib/o-chlorotoluene.xyz @@ -0,0 +1,17 @@ +15 +Generated by autodE on: 2021-10-13. E = -730.368448 Ha +C 2.20170 -0.16720 0.01637 +C 0.70810 -0.08361 0.00862 +C -0.09014 -1.23236 0.04956 +C -1.48087 -1.16076 0.04026 +C -2.11136 0.08027 -0.00937 +C -1.34696 1.24229 -0.05343 +C 0.04227 1.14965 -0.04682 +Cl 0.96772 2.61664 -0.10995 +H 2.62369 0.16971 -0.94463 +H 2.63404 0.48625 0.78817 +H 2.53718 -1.19678 0.20028 +H 0.40385 -2.20666 0.09339 +H -2.07301 -2.07730 0.07650 +H -3.20154 0.15252 -0.01318 +H -1.81464 2.22735 -0.09596 diff --git a/autodE/source/autode/solvent/lib/o-cresol.xyz b/autodE/source/autode/solvent/lib/o-cresol.xyz new file mode 100644 index 0000000000000000000000000000000000000000..e090c856ed12a5d9cc3b0835d92335d8c4ae3f7c --- /dev/null +++ b/autodE/source/autode/solvent/lib/o-cresol.xyz @@ -0,0 +1,18 @@ +16 +Generated by autodE on: 2021-10-13. E = -346.149851 Ha +C 2.09001 -0.34873 0.06201 +C 0.59644 -0.22293 0.00943 +C -0.23402 -1.34151 -0.09756 +C -1.62028 -1.22091 -0.15002 +C -2.19558 0.04808 -0.10194 +C -1.39266 1.17786 0.00851 +C -0.00402 1.04774 0.07639 +O 0.72822 2.17775 0.20586 +H 2.58667 0.26188 -0.71215 +H 2.50066 -0.04031 1.04052 +H 2.40282 -1.38832 -0.10513 +H 0.22866 -2.33146 -0.14453 +H -2.24558 -2.11141 -0.23654 +H -3.28114 0.16470 -0.15459 +H -1.82187 2.18077 0.04935 +H 1.66185 1.94700 0.25049 diff --git a/autodE/source/autode/solvent/lib/o-nitrotoluene.xyz b/autodE/source/autode/solvent/lib/o-nitrotoluene.xyz new file mode 100644 index 0000000000000000000000000000000000000000..b2e088296caaee9de4fdca33613dc79ba7cb2cb2 --- /dev/null +++ b/autodE/source/autode/solvent/lib/o-nitrotoluene.xyz @@ -0,0 +1,19 @@ +17 +Generated by autodE on: 2021-10-13. E = -475.199680 Ha +C -1.71718 -1.33027 0.00231 +C -0.41142 -0.59164 0.03404 +C 0.77824 -1.32393 0.16145 +C 2.02755 -0.71532 0.17600 +C 2.13544 0.66977 0.04567 +C 0.98187 1.42679 -0.09313 +C -0.26225 0.79669 -0.08168 +N -1.42329 1.68627 -0.19012 +O -1.31531 2.64346 -0.92434 +O -2.39999 1.42699 0.47910 +H -1.65932 -2.15582 -0.72206 +H -1.92576 -1.77356 0.98869 +H -2.56683 -0.68797 -0.24595 +H 0.70607 -2.41115 0.25325 +H 2.92475 -1.32801 0.28711 +H 3.11179 1.15846 0.05217 +H 1.01555 2.50913 -0.22262 diff --git a/autodE/source/autode/solvent/lib/o-xylene.xyz b/autodE/source/autode/solvent/lib/o-xylene.xyz new file mode 100644 index 0000000000000000000000000000000000000000..4f9ffbc0f18280f5a1af80cc7ddf33672638501a --- /dev/null +++ b/autodE/source/autode/solvent/lib/o-xylene.xyz @@ -0,0 +1,20 @@ +18 +Generated by autodE on: 2021-10-13. E = -310.296181 Ha +C -2.47254 0.05823 0.61012 +C -1.62946 -0.12628 -0.64914 +C -0.15019 -0.06102 -0.37724 +C 0.51444 -1.15219 0.19759 +C 1.87355 -1.08808 0.49684 +C 2.59340 0.07635 0.22778 +C 1.94700 1.16935 -0.34555 +C 0.58600 1.09897 -0.64310 +H -2.27037 1.03195 1.08403 +H -3.54738 0.00557 0.37640 +H -2.24850 -0.71738 1.35831 +H -1.87423 -1.09690 -1.11250 +H -1.89826 0.64528 -1.38770 +H -0.04803 -2.06531 0.41648 +H 2.37539 -1.95037 0.94331 +H 3.65801 0.13080 0.46810 +H 2.50618 2.08161 -0.56802 +H 0.08489 1.95952 -1.09560 diff --git a/autodE/source/autode/solvent/lib/p-isopropyltoluene.xyz b/autodE/source/autode/solvent/lib/p-isopropyltoluene.xyz new file mode 100644 index 0000000000000000000000000000000000000000..96004ec7bf3b3cc226d85bea2857d1fe12c13842 --- /dev/null +++ b/autodE/source/autode/solvent/lib/p-isopropyltoluene.xyz @@ -0,0 +1,26 @@ +24 +Generated by autodE on: 2021-10-13. E = -388.780568 Ha +C 3.78829 0.07206 -0.69926 +C 2.31249 -0.01456 -0.44567 +C 1.65329 0.91907 0.36029 +C 0.28914 0.81174 0.62509 +C -0.46726 -0.23676 0.09264 +C -1.95645 -0.36411 0.33274 +C -2.73986 0.38866 -0.74234 +C -2.37768 0.05535 1.73837 +C 0.19182 -1.16445 -0.72326 +C 1.55256 -1.05703 -0.98898 +H 4.03589 -0.20022 -1.73665 +H 4.34643 -0.61759 -0.04620 +H 4.17156 1.08427 -0.50524 +H 2.21995 1.74655 0.79647 +H -0.18736 1.55972 1.26322 +H -2.20090 -1.43503 0.21860 +H -3.82640 0.26375 -0.60264 +H -2.50994 1.46678 -0.71336 +H -2.47999 0.02075 -1.74672 +H -1.76385 -0.44285 2.50256 +H -2.28262 1.14292 1.88530 +H -3.43088 -0.20777 1.92177 +H -0.37711 -1.99044 -1.15954 +H 2.03896 -1.80061 -1.62708 diff --git a/autodE/source/autode/solvent/lib/p-xylene.xyz b/autodE/source/autode/solvent/lib/p-xylene.xyz new file mode 100644 index 0000000000000000000000000000000000000000..4f9ffbc0f18280f5a1af80cc7ddf33672638501a --- /dev/null +++ b/autodE/source/autode/solvent/lib/p-xylene.xyz @@ -0,0 +1,20 @@ +18 +Generated by autodE on: 2021-10-13. E = -310.296181 Ha +C -2.47254 0.05823 0.61012 +C -1.62946 -0.12628 -0.64914 +C -0.15019 -0.06102 -0.37724 +C 0.51444 -1.15219 0.19759 +C 1.87355 -1.08808 0.49684 +C 2.59340 0.07635 0.22778 +C 1.94700 1.16935 -0.34555 +C 0.58600 1.09897 -0.64310 +H -2.27037 1.03195 1.08403 +H -3.54738 0.00557 0.37640 +H -2.24850 -0.71738 1.35831 +H -1.87423 -1.09690 -1.11250 +H -1.89826 0.64528 -1.38770 +H -0.04803 -2.06531 0.41648 +H 2.37539 -1.95037 0.94331 +H 3.65801 0.13080 0.46810 +H 2.50618 2.08161 -0.56802 +H 0.08489 1.95952 -1.09560 diff --git a/autodE/source/autode/solvent/lib/pentanal.xyz b/autodE/source/autode/solvent/lib/pentanal.xyz new file mode 100644 index 0000000000000000000000000000000000000000..20492884bea522a76b3d9887f813b12b95e93127 --- /dev/null +++ b/autodE/source/autode/solvent/lib/pentanal.xyz @@ -0,0 +1,18 @@ +16 +Generated by autodE on: 2021-10-13. E = -271.272749 Ha +C 2.53020 -0.34330 0.82425 +C 1.42242 -0.02701 -0.15066 +C 0.00606 -0.12904 0.37606 +C -1.05613 0.26061 -0.63604 +C -2.46071 0.26219 -0.05550 +O 1.65601 0.29693 -1.29074 +H 3.49993 -0.18134 0.33657 +H 2.45809 -1.38863 1.16568 +H 2.45171 0.28788 1.72216 +H -0.14612 -1.16099 0.74524 +H -0.06025 0.49874 1.28295 +H -0.80591 1.25405 -1.04175 +H -0.99300 -0.42210 -1.49836 +H -2.54838 0.96250 0.79073 +H -2.74920 -0.73383 0.31895 +H -3.20491 0.56324 -0.80756 diff --git a/autodE/source/autode/solvent/lib/pentane.xyz b/autodE/source/autode/solvent/lib/pentane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..ca22eae43176117ddd899014f69b7eda9e6be63f --- /dev/null +++ b/autodE/source/autode/solvent/lib/pentane.xyz @@ -0,0 +1,19 @@ +17 +Generated by autodE on: 2021-10-13. E = -197.386174 Ha +C -2.30997 -0.66276 0.23123 +C -0.86040 -0.28723 0.50502 +C -0.22841 0.53430 -0.61349 +C 1.22589 0.92845 -0.36431 +C 2.20932 -0.23177 -0.41777 +H -2.93635 0.23455 0.10082 +H -2.74325 -1.25356 1.05279 +H -2.40316 -1.26182 -0.68932 +H -0.27267 -1.20571 0.67450 +H -0.79307 0.28447 1.44766 +H -0.82878 1.44820 -0.76687 +H -0.29783 -0.02875 -1.56245 +H 1.30097 1.43114 0.61664 +H 1.52575 1.68328 -1.11003 +H 3.23606 0.11001 -0.21385 +H 1.96860 -1.01420 0.31792 +H 2.20738 -0.70871 -1.41220 diff --git a/autodE/source/autode/solvent/lib/pentanoic acid.xyz b/autodE/source/autode/solvent/lib/pentanoic acid.xyz new file mode 100644 index 0000000000000000000000000000000000000000..9709f0ea56abf11ef18a8f244c9c5aee64453a44 --- /dev/null +++ b/autodE/source/autode/solvent/lib/pentanoic acid.xyz @@ -0,0 +1,19 @@ +17 +Generated by autodE on: 2021-10-13. E = -346.390825 Ha +C -2.01108 0.69132 0.03708 +C -1.13941 -0.09723 -0.92996 +C -0.20008 -1.09617 -0.26043 +C 0.88414 -0.45918 0.59628 +C 1.95193 0.22094 -0.21876 +O 2.66046 1.10617 0.49793 +O 2.19180 -0.00438 -1.37624 +H -1.41517 1.31177 0.72594 +H -2.69053 1.36981 -0.50143 +H -2.63129 0.02065 0.65541 +H -0.54687 0.59250 -1.55585 +H -1.78773 -0.64925 -1.63134 +H -0.79076 -1.78483 0.36711 +H 0.29061 -1.70813 -1.03317 +H 1.40665 -1.22557 1.19575 +H 0.48483 0.26066 1.32760 +H 3.34229 1.45092 -0.09949 diff --git a/autodE/source/autode/solvent/lib/pentyl amine.xyz b/autodE/source/autode/solvent/lib/pentyl amine.xyz new file mode 100644 index 0000000000000000000000000000000000000000..fa3c1017a66602e3a22ab5e8a022c2cfb229bfbb --- /dev/null +++ b/autodE/source/autode/solvent/lib/pentyl amine.xyz @@ -0,0 +1,21 @@ +19 +Generated by autodE on: 2021-10-13. E = -252.632821 Ha +N -2.53353 -0.36081 1.11304 +C -2.21320 0.07921 -0.23135 +C -0.78318 -0.28028 -0.61331 +C 0.29110 0.26016 0.31910 +C 1.69841 -0.10979 -0.13124 +C 2.80597 0.39624 0.77696 +H -3.46648 -0.06230 1.38526 +H -2.52310 -1.37795 1.16980 +H -2.89227 -0.32082 -1.01474 +H -2.32705 1.17737 -0.26740 +H -0.69452 -1.38259 -0.66264 +H -0.59693 0.08030 -1.64055 +H 0.20123 1.36036 0.38119 +H 0.10347 -0.11242 1.33998 +H 1.86063 0.27620 -1.15388 +H 1.77036 -1.20925 -0.21264 +H 3.79782 0.11659 0.38845 +H 2.78264 1.49405 0.87133 +H 2.71843 -0.02417 1.79164 diff --git a/autodE/source/autode/solvent/lib/pentyl ethanoate.xyz b/autodE/source/autode/solvent/lib/pentyl ethanoate.xyz new file mode 100644 index 0000000000000000000000000000000000000000..ea6cc9ba80c4a57296f4e99adb86e6d3af7a2d54 --- /dev/null +++ b/autodE/source/autode/solvent/lib/pentyl ethanoate.xyz @@ -0,0 +1,25 @@ +23 +Generated by autodE on: 2021-10-13. E = -424.857360 Ha +C 4.29774 -0.19359 -0.83937 +C 3.19233 0.37697 -0.00078 +O 1.99823 -0.13503 -0.32710 +C 0.88665 0.43206 0.35792 +C -0.39875 -0.05651 -0.26282 +C -1.60825 0.57313 0.41748 +C -2.95060 0.29989 -0.25282 +C -3.36752 -1.16073 -0.28037 +O 3.33587 1.21875 0.84682 +H 4.03789 -1.18162 -1.23934 +H 4.47933 0.48353 -1.68753 +H 5.21728 -0.23625 -0.24295 +H 0.94645 0.15987 1.42591 +H 0.96047 1.53066 0.30314 +H -0.39418 0.20082 -1.33686 +H -0.43900 -1.15658 -0.19564 +H -1.45836 1.66666 0.45734 +H -1.64935 0.23260 1.46854 +H -2.92251 0.69697 -1.28323 +H -3.72226 0.88907 0.26936 +H -2.66343 -1.77755 -0.85872 +H -3.41977 -1.58269 0.73626 +H -4.35816 -1.28061 -0.74433 diff --git a/autodE/source/autode/solvent/lib/perfluorobenzene.xyz b/autodE/source/autode/solvent/lib/perfluorobenzene.xyz new file mode 100644 index 0000000000000000000000000000000000000000..352857dd160ddad3625a38b4cc98f194ee3a6358 --- /dev/null +++ b/autodE/source/autode/solvent/lib/perfluorobenzene.xyz @@ -0,0 +1,14 @@ +12 +Generated by autodE on: 2021-10-13. E = -826.186274 Ha +F 0.22238 -2.70257 -0.03551 +C 0.10936 -1.38874 -0.01891 +C -1.15005 -0.79317 -0.00583 +F -2.23915 -1.53779 -0.01219 +C -1.25601 0.59654 0.02047 +F -2.44191 1.17037 0.04506 +C -0.10940 1.38865 0.01890 +F -0.22209 2.70256 0.03553 +C 1.15009 0.79322 0.00576 +F 2.23911 1.53785 0.01208 +C 1.25586 -0.59659 -0.02044 +F 2.44180 -1.17032 -0.04491 diff --git a/autodE/source/autode/solvent/lib/propanal.xyz b/autodE/source/autode/solvent/lib/propanal.xyz new file mode 100644 index 0000000000000000000000000000000000000000..4a04c7ebfe3adb33192b8cb95a2474003d630001 --- /dev/null +++ b/autodE/source/autode/solvent/lib/propanal.xyz @@ -0,0 +1,12 @@ +10 +Generated by autodE on: 2021-10-13. E = -192.791474 Ha +C -1.28021 0.14373 -0.13953 +C 0.00718 -0.00688 0.63727 +C 1.27670 -0.14282 -0.17071 +O 0.02001 -0.01512 1.84458 +H -2.12298 0.19568 0.56114 +H -1.25253 1.05928 -0.75270 +H -1.42098 -0.69911 -0.83446 +H 2.13448 -0.22512 0.50848 +H 1.40916 0.72463 -0.83695 +H 1.22916 -1.03418 -0.81712 diff --git a/autodE/source/autode/solvent/lib/propanenitrile.xyz b/autodE/source/autode/solvent/lib/propanenitrile.xyz new file mode 100644 index 0000000000000000000000000000000000000000..fa1c51480370a08ff00d245d05fdb5d919fa4ebc --- /dev/null +++ b/autodE/source/autode/solvent/lib/propanenitrile.xyz @@ -0,0 +1,11 @@ +9 +Generated by autodE on: 2021-10-13. E = -171.733680 Ha +C -0.93783 -0.41044 -0.01904 +C 0.22238 0.58166 0.02163 +C 1.52815 -0.06712 0.00718 +N 2.55302 -0.60397 -0.00902 +H -0.90617 -1.02208 -0.93258 +H -0.90949 -1.09258 0.84313 +H -1.89358 0.13272 0.00055 +H 0.16639 1.21030 0.92554 +H 0.17723 1.27151 -0.83728 diff --git a/autodE/source/autode/solvent/lib/propanoic acid.xyz b/autodE/source/autode/solvent/lib/propanoic acid.xyz new file mode 100644 index 0000000000000000000000000000000000000000..19528df1eb86a5f5d5e0b59633a87aa7a97363be --- /dev/null +++ b/autodE/source/autode/solvent/lib/propanoic acid.xyz @@ -0,0 +1,13 @@ +11 +Generated by autodE on: 2021-10-13. E = -267.908420 Ha +C -1.26536 0.55774 -0.12020 +C -0.32873 -0.60157 0.20552 +C 1.09823 -0.13044 0.25699 +O 1.76242 -0.40839 -0.87449 +O 1.59854 0.46867 1.17238 +H -1.04095 0.98467 -1.11029 +H -1.16626 1.36135 0.62385 +H -2.31010 0.21414 -0.12636 +H -0.42419 -1.39944 -0.54369 +H -0.57314 -1.01740 1.19385 +H 2.64953 -0.02930 -0.77275 diff --git a/autodE/source/autode/solvent/lib/propyl amine.xyz b/autodE/source/autode/solvent/lib/propyl amine.xyz new file mode 100644 index 0000000000000000000000000000000000000000..da546bd7b13262fa4b36fbccacdb5ab0e830f81c --- /dev/null +++ b/autodE/source/autode/solvent/lib/propyl amine.xyz @@ -0,0 +1,15 @@ +13 +Generated by autodE on: 2021-10-13. E = -174.152662 Ha +N -1.37123 0.91002 -0.22693 +C -0.87614 -0.20266 0.56192 +C 0.42398 -0.76757 0.00476 +C 1.54175 0.25555 -0.10653 +H -1.69914 0.57621 -1.13221 +H -2.17433 1.33846 0.22626 +H -0.68951 0.16927 1.58528 +H -1.59986 -1.04005 0.66338 +H 0.22250 -1.21212 -0.98750 +H 0.73776 -1.60848 0.64656 +H 1.21399 1.11481 -0.70831 +H 2.44162 -0.17720 -0.56972 +H 1.82861 0.64376 0.88465 diff --git a/autodE/source/autode/solvent/lib/propyl ethanoate.xyz b/autodE/source/autode/solvent/lib/propyl ethanoate.xyz new file mode 100644 index 0000000000000000000000000000000000000000..cd368763ec54eeb1bcd2eb3bb8f87fbbe28cd271 --- /dev/null +++ b/autodE/source/autode/solvent/lib/propyl ethanoate.xyz @@ -0,0 +1,19 @@ +17 +Generated by autodE on: 2021-10-13. E = -346.378714 Ha +C 3.17168 -0.24718 0.21527 +C 1.79522 -0.84053 0.12937 +O 0.85897 0.11560 0.05677 +C -0.48808 -0.32581 -0.07353 +C -1.36273 0.87441 -0.35312 +C -2.82696 0.49074 -0.50089 +O 1.54063 -2.01641 0.10892 +H 3.20923 0.54260 0.97762 +H 3.42614 0.22085 -0.74836 +H 3.89641 -1.03693 0.44085 +H -0.79254 -0.84271 0.85318 +H -0.55177 -1.07362 -0.88102 +H -1.23299 1.60595 0.46166 +H -0.99842 1.36949 -1.26882 +H -2.97436 -0.24096 -1.31076 +H -3.22211 0.03697 0.42256 +H -3.44811 1.36763 -0.73322 diff --git a/autodE/source/autode/solvent/lib/pyridine.xyz b/autodE/source/autode/solvent/lib/pyridine.xyz new file mode 100644 index 0000000000000000000000000000000000000000..db78e93d1e39d4236357c4db7748857d17c773d6 --- /dev/null +++ b/autodE/source/autode/solvent/lib/pyridine.xyz @@ -0,0 +1,13 @@ +11 +Generated by autodE on: 2021-10-13. E = -247.819738 Ha +C 0.95216 -1.10390 0.05873 +N -0.29251 -1.57180 0.08322 +C -1.28662 -0.68803 0.03665 +C -1.08885 0.69092 -0.03621 +C 0.21837 1.17080 -0.06156 +C 1.26431 0.25382 -0.01315 +H 1.75300 -1.85106 0.10071 +H -2.30408 -1.09693 0.05844 +H -1.94356 1.36951 -0.07485 +H 0.42215 2.24342 -0.12123 +H 2.30554 0.58336 -0.03076 diff --git a/autodE/source/autode/solvent/lib/quinoline.xyz b/autodE/source/autode/solvent/lib/quinoline.xyz new file mode 100644 index 0000000000000000000000000000000000000000..c8a4d1c04c21a9319371853efe61c83542662abd --- /dev/null +++ b/autodE/source/autode/solvent/lib/quinoline.xyz @@ -0,0 +1,19 @@ +17 +Generated by autodE on: 2021-10-13. E = -401.188847 Ha +C -0.15483 0.58343 0.07637 +C 0.99048 1.37796 0.32931 +C 2.23953 0.80452 0.37290 +C 2.39550 -0.58645 0.16726 +C 1.30204 -1.38319 -0.07522 +C 0.00071 -0.82189 -0.13197 +N -1.05158 -1.64117 -0.38089 +C -2.24866 -1.11262 -0.42828 +C -2.51845 0.26461 -0.23467 +C -1.46771 1.11121 0.01551 +H 0.86138 2.45043 0.49886 +H 3.11731 1.42205 0.57475 +H 3.39367 -1.02971 0.20721 +H 1.39201 -2.45852 -0.24044 +H -3.08010 -1.79742 -0.63565 +H -3.54435 0.63521 -0.28974 +H -1.62706 2.18147 0.17460 diff --git a/autodE/source/autode/solvent/lib/sec-butylbenzene.xyz b/autodE/source/autode/solvent/lib/sec-butylbenzene.xyz new file mode 100644 index 0000000000000000000000000000000000000000..96004ec7bf3b3cc226d85bea2857d1fe12c13842 --- /dev/null +++ b/autodE/source/autode/solvent/lib/sec-butylbenzene.xyz @@ -0,0 +1,26 @@ +24 +Generated by autodE on: 2021-10-13. E = -388.780568 Ha +C 3.78829 0.07206 -0.69926 +C 2.31249 -0.01456 -0.44567 +C 1.65329 0.91907 0.36029 +C 0.28914 0.81174 0.62509 +C -0.46726 -0.23676 0.09264 +C -1.95645 -0.36411 0.33274 +C -2.73986 0.38866 -0.74234 +C -2.37768 0.05535 1.73837 +C 0.19182 -1.16445 -0.72326 +C 1.55256 -1.05703 -0.98898 +H 4.03589 -0.20022 -1.73665 +H 4.34643 -0.61759 -0.04620 +H 4.17156 1.08427 -0.50524 +H 2.21995 1.74655 0.79647 +H -0.18736 1.55972 1.26322 +H -2.20090 -1.43503 0.21860 +H -3.82640 0.26375 -0.60264 +H -2.50994 1.46678 -0.71336 +H -2.47999 0.02075 -1.74672 +H -1.76385 -0.44285 2.50256 +H -2.28262 1.14292 1.88530 +H -3.43088 -0.20777 1.92177 +H -0.37711 -1.99044 -1.15954 +H 2.03896 -1.80061 -1.62708 diff --git a/autodE/source/autode/solvent/lib/tert-butylbenzene.xyz b/autodE/source/autode/solvent/lib/tert-butylbenzene.xyz new file mode 100644 index 0000000000000000000000000000000000000000..96004ec7bf3b3cc226d85bea2857d1fe12c13842 --- /dev/null +++ b/autodE/source/autode/solvent/lib/tert-butylbenzene.xyz @@ -0,0 +1,26 @@ +24 +Generated by autodE on: 2021-10-13. E = -388.780568 Ha +C 3.78829 0.07206 -0.69926 +C 2.31249 -0.01456 -0.44567 +C 1.65329 0.91907 0.36029 +C 0.28914 0.81174 0.62509 +C -0.46726 -0.23676 0.09264 +C -1.95645 -0.36411 0.33274 +C -2.73986 0.38866 -0.74234 +C -2.37768 0.05535 1.73837 +C 0.19182 -1.16445 -0.72326 +C 1.55256 -1.05703 -0.98898 +H 4.03589 -0.20022 -1.73665 +H 4.34643 -0.61759 -0.04620 +H 4.17156 1.08427 -0.50524 +H 2.21995 1.74655 0.79647 +H -0.18736 1.55972 1.26322 +H -2.20090 -1.43503 0.21860 +H -3.82640 0.26375 -0.60264 +H -2.50994 1.46678 -0.71336 +H -2.47999 0.02075 -1.74672 +H -1.76385 -0.44285 2.50256 +H -2.28262 1.14292 1.88530 +H -3.43088 -0.20777 1.92177 +H -0.37711 -1.99044 -1.15954 +H 2.03896 -1.80061 -1.62708 diff --git a/autodE/source/autode/solvent/lib/tetrachloroethene.xyz b/autodE/source/autode/solvent/lib/tetrachloroethene.xyz new file mode 100644 index 0000000000000000000000000000000000000000..74f15a428846997cf4659ec8b00e104592fd1033 --- /dev/null +++ b/autodE/source/autode/solvent/lib/tetrachloroethene.xyz @@ -0,0 +1,8 @@ +6 +Generated by autodE on: 2021-10-13. E = -1915.651564 Ha +Cl 1.47542 -1.45639 -0.54880 +C 0.66259 -0.01262 -0.12598 +Cl 1.62886 1.39493 -0.03100 +C -0.66263 0.01260 0.12595 +Cl -1.47544 1.45639 0.54872 +Cl -1.62881 -1.39501 0.03111 diff --git a/autodE/source/autode/solvent/lib/tetrahydrothiophene-s,s-dioxide.xyz b/autodE/source/autode/solvent/lib/tetrahydrothiophene-s,s-dioxide.xyz new file mode 100644 index 0000000000000000000000000000000000000000..097ddccd161ae2ea74118915ca133037ea5d04c4 --- /dev/null +++ b/autodE/source/autode/solvent/lib/tetrahydrothiophene-s,s-dioxide.xyz @@ -0,0 +1,17 @@ +15 +Generated by autodE on: 2021-10-13. E = -704.995499 Ha +O 2.07950 1.52773 -0.30454 +S 1.09176 0.85373 0.53010 +C 1.02031 -0.90777 0.10186 +C -0.46268 -1.22115 0.03878 +C -1.12111 -0.03428 -0.67515 +C -0.59699 1.24682 -0.03932 +O 1.12309 0.96951 1.98706 +H 1.52404 -1.01338 -0.86952 +H 1.58387 -1.43797 0.87953 +H -0.86557 -1.32004 1.05991 +H -0.65544 -2.16730 -0.48729 +H -0.83944 -0.05391 -1.74120 +H -2.21844 -0.08579 -0.62190 +H -0.51782 2.10148 -0.72390 +H -1.14500 1.54213 0.86569 diff --git a/autodE/source/autode/solvent/lib/tetralin.xyz b/autodE/source/autode/solvent/lib/tetralin.xyz new file mode 100644 index 0000000000000000000000000000000000000000..45ac13e260f2a604140d9995ebca218bba858172 --- /dev/null +++ b/autodE/source/autode/solvent/lib/tetralin.xyz @@ -0,0 +1,24 @@ +22 +Generated by autodE on: 2021-10-13. E = -387.589465 Ha +C 0.28863 -0.70173 -0.24014 +C 0.45255 0.60022 0.25988 +C -0.73621 1.47952 0.55636 +C -2.01746 1.03534 -0.13629 +C -2.22724 -0.46212 0.03176 +C -1.07665 -1.24331 -0.58770 +C 1.74585 1.06989 0.52230 +C 2.86636 0.27689 0.30712 +C 2.70291 -1.01841 -0.18369 +C 1.42429 -1.49256 -0.45363 +H -0.49414 2.52145 0.29042 +H -0.89945 1.47525 1.65049 +H -1.96341 1.27952 -1.21214 +H -2.87238 1.59834 0.26955 +H -3.17839 -0.77867 -0.42477 +H -2.30017 -0.69986 1.10780 +H -1.13206 -2.30432 -0.29387 +H -1.18046 -1.22940 -1.68875 +H 1.86793 2.08321 0.91565 +H 3.86292 0.66845 0.52658 +H 3.57201 -1.65490 -0.36479 +H 1.29466 -2.50301 -0.85224 diff --git a/autodE/source/autode/solvent/lib/thf.xyz b/autodE/source/autode/solvent/lib/thf.xyz new file mode 100644 index 0000000000000000000000000000000000000000..92edf1cd8634bd54e9a392a7dd053717f349d65b --- /dev/null +++ b/autodE/source/autode/solvent/lib/thf.xyz @@ -0,0 +1,15 @@ +13 +Generated by autodE on: 2021-10-13. E = -232.017669 Ha +C -0.83084 -0.65604 -0.05626 +C 0.20496 -0.32122 1.01120 +C 1.27935 0.33116 0.15873 +O 0.57924 1.08517 -0.80379 +C -0.72199 0.55427 -0.98511 +H -1.84518 -0.80798 0.34011 +H -0.53848 -1.57476 -0.59052 +H -0.20440 0.40947 1.72814 +H 0.56283 -1.19406 1.57630 +H 1.91184 -0.44286 -0.32497 +H 1.94649 1.00342 0.72057 +H -1.46466 1.33263 -0.73065 +H -0.87907 0.28071 -2.04384 diff --git a/autodE/source/autode/solvent/lib/thiophene.xyz b/autodE/source/autode/solvent/lib/thiophene.xyz new file mode 100644 index 0000000000000000000000000000000000000000..8df6fe5e2c6d544c79ceec9a9fda0e412a642361 --- /dev/null +++ b/autodE/source/autode/solvent/lib/thiophene.xyz @@ -0,0 +1,11 @@ +9 +Generated by autodE on: 2021-10-13. E = -552.455513 Ha +C -1.22877 0.57606 0.01683 +C -0.71862 -0.69579 -0.01647 +C 0.70498 -0.70962 -0.01766 +C 1.23962 0.55211 0.01484 +S 0.01711 1.75533 0.04774 +H -2.27286 0.88702 0.02180 +H -1.34061 -1.59127 -0.04126 +H 1.30958 -1.61692 -0.04359 +H 2.28948 0.84307 0.01778 diff --git a/autodE/source/autode/solvent/lib/thiophenol.xyz b/autodE/source/autode/solvent/lib/thiophenol.xyz new file mode 100644 index 0000000000000000000000000000000000000000..f739b2c45c5e43e346303f15c65c4993c6156aa3 --- /dev/null +++ b/autodE/source/autode/solvent/lib/thiophenol.xyz @@ -0,0 +1,15 @@ +13 +Generated by autodE on: 2021-10-13. E = -629.745015 Ha +S 2.80529 0.48349 -0.27784 +C 1.06948 0.20642 -0.09658 +C 0.23301 1.32947 -0.05089 +C -1.14633 1.16990 0.04192 +C -1.70984 -0.10388 0.10012 +C -0.87764 -1.22108 0.06843 +C 0.50335 -1.07259 -0.03297 +H 3.19873 -0.76965 0.02966 +H 0.66704 2.33206 -0.08312 +H -1.78593 2.05481 0.07722 +H -2.79260 -0.22764 0.17604 +H -1.30879 -2.22418 0.11852 +H 1.14412 -1.95702 -0.07070 diff --git a/autodE/source/autode/solvent/lib/toluene.xyz b/autodE/source/autode/solvent/lib/toluene.xyz new file mode 100644 index 0000000000000000000000000000000000000000..bedc287bb87527b898c97b2bab321292caa24203 --- /dev/null +++ b/autodE/source/autode/solvent/lib/toluene.xyz @@ -0,0 +1,17 @@ +15 +Generated by autodE on: 2021-10-13. E = -271.056121 Ha +C 2.22130 0.03794 -0.01136 +C 0.72169 0.01803 -0.01450 +C 0.02696 -1.19481 0.06851 +C -1.36484 -1.22458 0.08570 +C -2.09107 -0.03653 0.01727 +C -1.41374 1.17777 -0.06652 +C -0.01981 1.20229 -0.08211 +H 2.61811 -0.10517 1.00779 +H 2.63658 -0.76864 -0.63243 +H 2.61433 0.99222 -0.39041 +H 0.59127 -2.13035 0.12270 +H -1.88798 -2.18158 0.15368 +H -3.18368 -0.05895 0.02627 +H -1.97466 2.11344 -0.12846 +H 0.50555 2.15891 -0.15614 diff --git a/autodE/source/autode/solvent/lib/trans-1,2-dichloroethen.xyz b/autodE/source/autode/solvent/lib/trans-1,2-dichloroethen.xyz new file mode 100644 index 0000000000000000000000000000000000000000..e11e904631d909a63d48da2a530e94fb1f2053fe --- /dev/null +++ b/autodE/source/autode/solvent/lib/trans-1,2-dichloroethen.xyz @@ -0,0 +1,8 @@ +6 +Generated by autodE on: 2021-10-13. E = -997.046938 Ha +Cl 1.61966 1.11446 0.31989 +C 0.61174 -0.21388 -0.06646 +C -0.63507 -0.10163 -0.52716 +Cl -1.42976 1.38831 -0.80572 +H 1.05983 -1.19727 0.09187 +H -1.22641 -0.98999 -0.75652 diff --git a/autodE/source/autode/solvent/lib/trans-decalin.xyz b/autodE/source/autode/solvent/lib/trans-decalin.xyz new file mode 100644 index 0000000000000000000000000000000000000000..578cff47422338f0778ffa7bef7513a67d34e85d --- /dev/null +++ b/autodE/source/autode/solvent/lib/trans-decalin.xyz @@ -0,0 +1,30 @@ +28 +Generated by autodE on: 2021-10-13. E = -391.199240 Ha +C -0.24051 0.65119 0.71978 +C -1.61595 0.74507 0.05551 +C -1.60979 1.53879 -1.24083 +C -0.59913 0.95959 -2.22142 +C 0.78868 0.93057 -1.59511 +C 0.85529 0.17958 -0.26317 +C 0.82699 -1.34251 -0.39784 +C 1.14046 -2.03494 0.94458 +C 0.95010 -1.10049 2.14270 +C -0.30654 -0.25697 1.96838 +H 0.04005 1.67163 1.03862 +H -2.33160 1.17956 0.77340 +H -1.98283 -0.27648 -0.15549 +H -1.34804 2.59081 -1.02380 +H -2.61893 1.55054 -1.68463 +H -0.57957 1.54564 -3.15401 +H -0.91304 -0.05998 -2.50750 +H 1.10849 1.97350 -1.42008 +H 1.52163 0.49983 -2.29687 +H 1.82957 0.43859 0.18683 +H 1.54295 -1.66066 -1.17196 +H -0.16238 -1.66071 -0.76677 +H 2.17034 -2.42674 0.94330 +H 0.48805 -2.91415 1.06730 +H 1.82399 -0.43689 2.25357 +H 0.90094 -1.69001 3.07236 +H -0.51216 0.34831 2.86591 +H -1.16735 -0.94267 1.86732 diff --git a/autodE/source/autode/solvent/lib/tributylphosphate.xyz b/autodE/source/autode/solvent/lib/tributylphosphate.xyz new file mode 100644 index 0000000000000000000000000000000000000000..0b786480648317584728c2ad845a577cc75ec542 --- /dev/null +++ b/autodE/source/autode/solvent/lib/tributylphosphate.xyz @@ -0,0 +1,46 @@ +44 +Generated by autodE on: 2021-10-13. E = -1114.221495 Ha +O 0.21656 -1.66423 2.04653 +P 0.52469 -1.06527 0.73507 +O -0.75602 -0.70363 -0.17810 +C -2.03020 -1.30401 0.06823 +C -3.11295 -0.32093 -0.32752 +C -4.52242 -0.74209 0.08727 +C -4.93552 -2.14765 -0.33454 +O 1.30928 0.33485 0.76255 +C 1.22315 1.20271 1.89684 +C 0.92132 2.61186 1.44435 +C -0.47454 2.79484 0.86270 +C -0.71565 4.20867 0.35626 +O 1.45846 -1.94856 -0.20876 +C 1.69454 -1.60436 -1.57762 +C 3.12585 -1.91748 -1.95633 +C 4.17101 -0.89652 -1.51730 +C 4.40619 -0.79729 -0.01855 +H -2.09773 -2.23237 -0.52143 +H -2.09968 -1.57999 1.13354 +H -3.06488 -0.16211 -1.41843 +H -2.87160 0.64888 0.13748 +H -5.23498 -0.01210 -0.33030 +H -4.61684 -0.65094 1.18395 +H -5.97571 -2.35684 -0.04125 +H -4.86237 -2.28700 -1.42576 +H -4.31010 -2.91795 0.14069 +H 2.19279 1.15626 2.41680 +H 0.45608 0.82392 2.59224 +H 1.67897 2.91054 0.69947 +H 1.06167 3.28280 2.31039 +H -1.22066 2.53719 1.63628 +H -0.62395 2.06633 0.04853 +H -0.58400 4.94792 1.16328 +H -0.00539 4.47036 -0.44351 +H -1.73035 4.33275 -0.05262 +H 0.99372 -2.19106 -2.19181 +H 1.47285 -0.53756 -1.74907 +H 3.15188 -2.00337 -3.05597 +H 3.37623 -2.91705 -1.56109 +H 3.87949 0.09355 -1.91293 +H 5.11944 -1.15226 -2.01948 +H 4.67817 -1.77533 0.40805 +H 3.51052 -0.44052 0.50528 +H 5.22309 -0.09488 0.20644 diff --git a/autodE/source/autode/solvent/lib/trichloroethene.xyz b/autodE/source/autode/solvent/lib/trichloroethene.xyz new file mode 100644 index 0000000000000000000000000000000000000000..552bf6d4fb85037595f81ee3c5d9d70abb877edc --- /dev/null +++ b/autodE/source/autode/solvent/lib/trichloroethene.xyz @@ -0,0 +1,8 @@ +6 +Generated by autodE on: 2021-10-13. E = -1456.350074 Ha +Cl 1.76232 -1.02256 -0.69174 +C 0.61606 0.06406 -0.01375 +Cl 1.25669 1.47589 0.70711 +C -0.69316 -0.21067 -0.05965 +Cl -1.88996 0.81761 0.59241 +H -1.05196 -1.12433 -0.53448 diff --git a/autodE/source/autode/solvent/lib/trichloromethane.xyz b/autodE/source/autode/solvent/lib/trichloromethane.xyz new file mode 100644 index 0000000000000000000000000000000000000000..cbb5d55db08bce69be77a42386490e65b75086ed --- /dev/null +++ b/autodE/source/autode/solvent/lib/trichloromethane.xyz @@ -0,0 +1,7 @@ +5 +Generated by autodE on: 2021-10-13. E = -1418.332952 Ha +Cl -0.98002 -1.38297 -0.38328 +C 0.00150 0.00145 0.09962 +Cl -0.71519 1.51885 -0.44030 +Cl 1.66435 -0.15487 -0.46973 +H 0.02937 0.01754 1.19369 diff --git a/autodE/source/autode/solvent/lib/triethylamine.xyz b/autodE/source/autode/solvent/lib/triethylamine.xyz new file mode 100644 index 0000000000000000000000000000000000000000..87690d3a3946913673bad6fe98a55e71acde0436 --- /dev/null +++ b/autodE/source/autode/solvent/lib/triethylamine.xyz @@ -0,0 +1,24 @@ +22 +Generated by autodE on: 2021-10-13. E = -291.859844 Ha +C -1.18834 -2.35054 0.06359 +C -1.28782 -0.84174 -0.09276 +N -0.00198 -0.21287 -0.31514 +C -0.05547 1.00966 -1.09152 +C -0.62608 2.24813 -0.40106 +C 0.83163 -0.16277 0.86733 +C 2.30288 0.04690 0.55794 +H -2.18878 -2.80338 0.13059 +H -0.64232 -2.64251 0.97339 +H -0.65865 -2.78868 -0.79530 +H -1.82600 -0.41182 0.78267 +H -1.92051 -0.62328 -0.96853 +H -0.64621 0.79171 -1.99741 +H 0.95931 1.24076 -1.45589 +H -0.62780 3.10189 -1.09647 +H -0.03129 2.54512 0.47625 +H -1.66117 2.08864 -0.05924 +H 0.72492 -1.12650 1.38659 +H 0.48562 0.60439 1.59760 +H 2.63908 -0.68486 -0.19130 +H 2.90958 -0.08157 1.46673 +H 2.50951 1.05321 0.16193 diff --git a/autodE/source/autode/solvent/lib/water.xyz b/autodE/source/autode/solvent/lib/water.xyz new file mode 100644 index 0000000000000000000000000000000000000000..4b5522f387418d56d5d980b7ad7de2fd5facf5ec --- /dev/null +++ b/autodE/source/autode/solvent/lib/water.xyz @@ -0,0 +1,5 @@ +3 +Generated by autodE on: 2021-10-13. E = -76.276703 Ha +O -0.00031 0.39939 0.00000 +H -0.75346 -0.20028 0.00000 +H 0.75376 -0.19911 0.00000 diff --git a/autodE/source/autode/solvent/lib/xenon.xyz b/autodE/source/autode/solvent/lib/xenon.xyz new file mode 100644 index 0000000000000000000000000000000000000000..5453405a38eea6e509c260f1d34eaa16f19d7de3 --- /dev/null +++ b/autodE/source/autode/solvent/lib/xenon.xyz @@ -0,0 +1,3 @@ +1 +Generated by autodE on: 2021-10-13. E = -329.377756 Ha +Xe 0.00100 0.00100 0.00100 diff --git a/autodE/source/autode/solvent/lib/xylene mixture.xyz b/autodE/source/autode/solvent/lib/xylene mixture.xyz new file mode 100644 index 0000000000000000000000000000000000000000..4f9ffbc0f18280f5a1af80cc7ddf33672638501a --- /dev/null +++ b/autodE/source/autode/solvent/lib/xylene mixture.xyz @@ -0,0 +1,20 @@ +18 +Generated by autodE on: 2021-10-13. E = -310.296181 Ha +C -2.47254 0.05823 0.61012 +C -1.62946 -0.12628 -0.64914 +C -0.15019 -0.06102 -0.37724 +C 0.51444 -1.15219 0.19759 +C 1.87355 -1.08808 0.49684 +C 2.59340 0.07635 0.22778 +C 1.94700 1.16935 -0.34555 +C 0.58600 1.09897 -0.64310 +H -2.27037 1.03195 1.08403 +H -3.54738 0.00557 0.37640 +H -2.24850 -0.71738 1.35831 +H -1.87423 -1.09690 -1.11250 +H -1.89826 0.64528 -1.38770 +H -0.04803 -2.06531 0.41648 +H 2.37539 -1.95037 0.94331 +H 3.65801 0.13080 0.46810 +H 2.50618 2.08161 -0.56802 +H 0.08489 1.95952 -1.09560 diff --git a/autodE/source/autode/solvent/solvents.py b/autodE/source/autode/solvent/solvents.py new file mode 100644 index 0000000000000000000000000000000000000000..ebd592dd207ab290bd33a3af6c8d6aa459332ba0 --- /dev/null +++ b/autodE/source/autode/solvent/solvents.py @@ -0,0 +1,2310 @@ +import os +from abc import ABC, abstractmethod +from typing import Optional, List, TYPE_CHECKING +from copy import deepcopy + +from autode.log import logger +from autode.input_output import xyz_file_to_atoms +from autode.exceptions import SolventNotFound + +if TYPE_CHECKING: + from autode.solvent.explicit_solvent import ExplicitSolvent + from autode.species.species import Species + from autode.atoms import Atoms + + +def get_solvent( + solvent_name: Optional[str], kind: str, num: Optional[int] = None +) -> Optional["Solvent"]: + """ + For a named solvent return the Solvent which matches one of the aliases + + --------------------------------------------------------------------------- + Arguments: + solvent_name: Name of the solvent e.g. DCM. Not case-sensitive + + kind: Kind of solvent. One of: {"implicit", "explicit"} + + num: Number of solvent molecules to include in the explicit solvent + + Returns: + (autode.solvent.solvents.Solvent | None): Solvent + + Raises: + (ValueError): If both explicit and implicit solvent are selected + """ + kind = kind.lower() + + if kind not in ("explicit", "implicit"): + raise ValueError(f"Solvent must be explicit or implicit. Had: {kind}") + + if solvent_name is None: + return None + + if kind == "explicit" and num is None: + raise ValueError( + "Requested an explicit solvent but number of explicit" + " solvent molecules was not defined" + ) + + for solvent in solvents: + # Comparisons of solvents are not case sensitive + if solvent_name.lower() not in solvent.aliases: + continue + + if solvent.is_implicit: + return ( + solvent if kind == "implicit" else solvent.to_explicit(num=num) # type: ignore[arg-type] + ) + + # Allow for solvent.is_explicit in solvents? + + raise SolventNotFound( + "No matching solvent in the library for " f"{solvent_name}" + ) + + +class Solvent(ABC): + def __init__( + self, + name: str, + smiles: Optional[str] = None, + aliases: Optional[List[str]] = None, + **kwargs, + ): + """ + Abstract base class for a solvent. As electronic structure methods + implement implicit solvation without a unique list of solvents there + needs to be conversion between them, while also allowing for user + specifying one possibility from a list of aliases + + ---------------------------------------------------------------------- + Arguments: + name (str): Unique name of the solvent + + smiles (str | None): SMILES string + + aliases (list(str) | None): Different names for the same solvent + e.g. water and H2O. If None then will + only use the name as an alias + + Keyword Arguments: + kwargs (str): Name of the solvent in the electronic structure + package e.g. Solvent(..., orca='water') + """ + + self.name = name + self.smiles = smiles + self.aliases = [name.lower()] + + if aliases is not None: + self.aliases.extend(alias.lower() for alias in aliases) + + self.g09: Optional[str] = None + self.g16: Optional[str] = None + self.qchem: Optional[str] = None + self.orca: Optional[str] = None + self.xtb: Optional[str] = None + self.nwchem: Optional[str] = None + # Add attributes for all the methods specified e.g. initialisation with + # orca='water' -> self.orca = 'water' + self.__dict__.update(kwargs) + + # Gaussian 09 and Gaussian 16 solvents are named the same + if "g09" in kwargs.keys(): + self.g16 = kwargs["g09"] + + def __repr__(self): + return f"Solvent({self.name})" + + def __str__(self): + return self.name + + def __eq__(self, other): + """Determine if two solvents are the same based on name and SMILES""" + if other is None: + return False + + return self.name == other.name and self.smiles == other.smiles + + def copy(self) -> "Solvent": + """Return a copy of this solvent""" + return deepcopy(self) + + @property + @abstractmethod + def atoms(self) -> Optional["Atoms"]: + """Atoms in this solvent""" + + @property + def dielectric(self) -> Optional[float]: + """ + Dielectric constant (ε) of this solvent. Used in implicit solvent + models to determine the electrostatic interaction + + Returns: + (float | None): Dielectric, or None if unknown + """ + for alias in self.aliases: + if alias in _solvents_and_dielectrics: + return _solvents_and_dielectrics[alias] + + logger.warning( + f"Could not find a dielectric for: {self}. " f"Returning None" + ) + return None + + @property + @abstractmethod + def is_implicit(self) -> bool: + """Is this solvent implicit and just defined by a dielectric""" + + @property + def is_explicit(self) -> bool: + """Is this solvent explicit i.e. has atoms in space""" + return not self.is_implicit + + def randomise_around(self, solute: "Species") -> None: + raise RuntimeError("Method may implemented in subclass") + + def to_explicit(self, num: int) -> "ExplicitSolvent": + raise RuntimeError("Method may implemented in subclass") + + +class ImplicitSolvent(Solvent): + """Implicit solvent""" + + @property + def is_implicit(self) -> bool: + """Is this solvent implicit? + + Returns: + (bool): True + """ + return True + + def to_explicit(self, num: int) -> "ExplicitSolvent": + """ + Convert this implicit solvent into an explicit one + + ----------------------------------------------------------------------- + Arguments: + num (int): Number of explicit solvent molecules to include + + Raises: + (IOError): If the expected 3D structure cannot be located + + Returns: + (autode.solvent.explicit_solvent.ExplicitSolvent): Solvent + """ + from autode.species.species import Species # cyclic imports.. + from autode.solvent.explicit_solvent import ExplicitSolvent + + here = os.path.dirname(os.path.abspath(__file__)) + xyz_path = os.path.join(here, "lib", f"{self.name}.xyz") + + if not os.path.exists(xyz_path): + raise IOError( + f"Could not convert {self.name} to explicit solvent " + f"{xyz_path} did not exist" + ) + + # Solvent must be neutral and with a spin multiplicity of one + solvent_mol = Species( + name=self.name, charge=0, mult=1, atoms=xyz_file_to_atoms(xyz_path) + ) + + return ExplicitSolvent( + solvent=solvent_mol, num=num, solute=None, aliases=self.aliases + ) + + @property + def atoms(self) -> Optional["Atoms"]: + logger.warning("Implicit solvent have no atoms") + return None + + +solvents = [ + ImplicitSolvent( + name="water", + smiles="O", + aliases=["water", "h2o"], + orca="water", + g09="Water", + nwchem="water", + xtb="Water", + mopac="water", + qchem="water", + ), + ImplicitSolvent( + name="dichloromethane", + smiles="ClCCl", + aliases=["dichloromethane", "methyl dichloride", "dcm"], + orca="dichloromethane", + g09="Dichloromethane", + nwchem="dcm", + xtb="CH2Cl2", + mopac="dichloromethane", + qchem="dichloromethane", + ), + ImplicitSolvent( + name="acetone", + smiles="CC(C)=O", + aliases=["acetone", "propanone"], + orca="acetone", + g09="Acetone", + nwchem="acetone", + xtb="Acetone", + mopac="acetone", + qchem="acetone", + ), + ImplicitSolvent( + name="acetonitrile", + smiles="CC#N", + aliases=["acetonitrile", "mecn", "ch3cn"], + orca="acetonitrile", + g09="Acetonitrile", + nwchem="acetntrl", + xtb="Acetonitrile", + mopac="acetonitrile", + qchem="acetonitrile", + ), + ImplicitSolvent( + name="benzene", + smiles="C1=CC=CC=C1", + aliases=["benzene", "cyclohexatriene"], + orca="benzene", + g09="Benzene", + nwchem="benzene", + xtb="Benzene", + mopac="benzene", + qchem="benzene", + ), + ImplicitSolvent( + name="trichloromethane", + smiles="ClC(Cl)Cl", + aliases=[ + "chloroform", + "trichloromethane", + "chcl3", + "methyl trichloride", + ], + orca="chloroform", + g09="Chloroform", + nwchem="chcl3", + xtb="CHCl3", + mopac="chloroform", + qchem="trichloromethane", + ), + ImplicitSolvent( + name="cs2", + smiles="S=C=S", + aliases=["cs2", "methanedithione", "carbon bisulfide"], + orca="carbon disulfide", + g09="CarbonDiSulfide", + nwchem="cs2", + xtb="CS2", + mopac="cs2", + qchem="carbon disulfide", + ), + ImplicitSolvent( + name="dmf", + smiles="O=CN(C)C", + aliases=["dmf", "dimethylformamide", "n,n-dimethylformamide"], + orca="n,n-dimethylformamide", + g09="n,n-DiMethylFormamide", + nwchem="dmf", + xtb="DMF", + mopac="n,n-dimethylformamide", + qchem="dimethylformamide", + ), + ImplicitSolvent( + name="dmso", + smiles="O=S(C)C", + aliases=["dmso", "dimethylsulfoxide"], + orca="dimethylsulfoxide", + g09="DiMethylSulfoxide", + nwchem="dmso", + xtb="DMSO", + mopac="dmso", + ), + ImplicitSolvent( + name="diethyl ether", + smiles="CCOCC", + aliases=["diethyl ether", "ether", "Ethoxyethane"], + orca="diethyl ether", + g09="DiethylEther", + nwchem="ether", + xtb="Ether", + mopac="ether", + qchem="diethyl ether", + ), + ImplicitSolvent( + name="methanol", + smiles="CO", + aliases=["methanol", "meoh"], + orca="methanol", + g09="Methanol", + nwchem="methanol", + xtb="Methanol", + mopac="methanol", + qchem="ethanol", + ), + ImplicitSolvent( + name="hexane", + smiles="CCCCCC", + aliases=["hexane", "n-hexane"], + orca="n-hexane", + g09="n-Hexane", + nwchem="hexane", + xtb="n-Hexane", + mopac="hexane", + qchem="hexane", + ), + ImplicitSolvent( + name="thf", + smiles="C1CCOC1", + aliases=["thf", "tetrahydrofuran", "oxolane"], + orca="tetrahydrofuran", + g09="TetraHydroFuran", + nwchem="thf", + xtb="THF", + mopac="tetrahydrofuran", + qchem="tetrahydrofuran", + ), + ImplicitSolvent( + name="toluene", + smiles="CC1=CC=CC=C1", + aliases=["toluene", "methylbenzene", "phenyl methane"], + orca="toluene", + g09="Toluene", + nwchem="toluene", + xtb="Toluene", + mopac="toluene", + qchem="benzene", + ), + ImplicitSolvent( + name="acetic acid", + smiles="CC(O)=O", + aliases=["acetic acid", "ethanoic acid"], + orca="acetic acid", + g09="AceticAcid", + nwchem="acetacid", + mopac="acetic acid", + qchem="acetic acid", + ), + ImplicitSolvent( + name="1-butanol", + smiles="CCCCO", + aliases=["1-butanol", "butanol", "n-butanol", "butan-1-ol"], + orca="1-butanol", + g09="1-Butanol", + nwchem="butanol", + mopac="1-butanol", + qchem="1-butanol", + ), + ImplicitSolvent( + name="2-butanol", + smiles="CC(O)CC", + aliases=["2-butanol", "sec-butanol", "butan-2-ol"], + orca="2-butanol", + g09="2-Butanol", + nwchem="butanol2", + mopac="2-butanol", + qchem="sec-butanol", + ), + ImplicitSolvent( + name="acetophenone", + smiles="CC(C1=CC=CC=C1)=O", + aliases=["acetophenone", "phenylacetone", "phenylethanone"], + orca="acetophenone", + g09="AcetoPhenone", + nwchem="acetphen", + mopac="acetophenone", + qchem="acetone", + ), + ImplicitSolvent( + name="aniline", + smiles="NC1=CC=CC=C1", + aliases=["aniline", "benzenamine", "phenylamine"], + orca="aniline", + g09="Aniline", + nwchem="aniline", + mopac="aniline", + qchem="aniline", + ), + ImplicitSolvent( + name="anisole", + smiles="COC1=CC=CC=C1", + aliases=["anisole", "methoxybenzene", "phenoxymethane"], + orca="anisole", + g09="Anisole", + nwchem="anisole", + mopac="anisole", + qchem="anisole", + ), + ImplicitSolvent( + name="benzaldehyde", + smiles="O=CC1=CC=CC=C1", + aliases=["benzaldehyde", "phenylmethanal"], + orca="benzaldehyde", + g09="Benzaldehyde", + nwchem="benzaldh", + mopac="benzaldehyde", + qchem="benzaldehyde", + ), + ImplicitSolvent( + name="benzonitrile", + smiles="N#CC1=CC=CC=C1", + aliases=["benzonitrile", "cyanobenzene", "phenyl cyanide"], + orca="benzonitrile", + g09="BenzoNitrile", + nwchem="benzntrl", + mopac="benzonitrile", + qchem="benzene", + ), + ImplicitSolvent( + name="benzyl chloride", + smiles="ClCC1=CC=CC=C1", + aliases=[ + "benzyl chloride", + "(chloromethyl)benzene", + "Chloromethyl benzene", + "a-chlorotoluene", + ], + orca="a-chlorotoluene", + g09="a-ChloroToluene", + nwchem="benzylcl", + mopac="benzyl chloride", + qchem="benzene", + ), + ImplicitSolvent( + name="1-bromo-2-methylpropane", + smiles="CC(C)CBr", + aliases=["1-bromo-2-methylpropane", "isobutyl bromide"], + orca="1-bromo-2-methylpropane", + g09="1-Bromo-2-MethylPropane", + nwchem="brisobut", + mopac="isobutyl bromide", + qchem="1-bromo-2-methylpropane", + ), + ImplicitSolvent( + name="bromobenzene", + smiles="BrC1=CC=CC=C1", + aliases=["bromobenzene", "phenyl bromide"], + orca="bromobenzene", + g09="BromoBenzene", + nwchem="brbenzen", + mopac="bromobenzene", + qchem="benzene", + ), + ImplicitSolvent( + name="bromoethane", + smiles="CCBr", + aliases=["bromoethane", "ethyl bromide", "etbr"], + orca="bromoethane", + g09="BromoEthane", + nwchem="brethane", + mopac="bromoethane", + qchem="bromoethane", + ), + ImplicitSolvent( + name="bromoform", + smiles="BrC(Br)Br", + aliases=["bromoform", "tribromomethane", "methyl tribromide", "chbr3"], + orca="bromoform", + g09="Bromoform", + nwchem="bromform", + mopac="bromoform", + qchem="tribromomethane", + ), + ImplicitSolvent( + name="1-bromooctane", + smiles="CCCCCCCCBr", + aliases=[ + "1-bromooctane", + "bromooctane", + "octyl bromide", + "1-octyl bromide", + ], + orca="1-bromooctane", + g09="1-BromoOctane", + nwchem="broctane", + mopac="bromooctane", + qchem="bromooctane", + ), + ImplicitSolvent( + name="1-bromopentane", + smiles="CCCCCBr", + aliases=["1-bromopentane", "bromopentane", "pentyl bromide"], + orca="1-bromopentane", + g09="1-BromoPentane", + nwchem="brpentan", + mopac="bromopentane", + qchem="1-bromopentane", + ), + ImplicitSolvent( + name="butantal", + smiles="CCCC=O", + aliases=["butanal", "butyraldehyde"], + orca="butanal", + g09="Butanal", + nwchem="butanal", + mopac="butanal", + qchem="butanal", + ), + ImplicitSolvent( + name="butanone", + smiles="CC(CC)=O", + aliases=[ + "butanone", + "2-butanone", + "butan-2-one", + "methyl ethyl ketone", + "ethyl methyl ketone", + ], + orca="butanone", + g09="Butanone", + nwchem="butanone", + mopac="2-butanone", + qchem="butanone", + ), + ImplicitSolvent( + name="carbon tetrachloride", + smiles="ClC(Cl)(Cl)Cl", + aliases=["carbon tetrachloride", "ccl4", "tetrachloromethane"], + orca="carbon tetrachloride", + g09="CarbonTetraChloride", + nwchem="carbntet", + mopac="carbon tetrachloride", + qchem="carbon tetrachloride", + ), + ImplicitSolvent( + name="chlorobenzene", + smiles="ClC1=CC=CC=C1", + aliases=["chlorobenzene", "benzene chloride", "phenyl chloride"], + orca="chlorobenzene", + g09="ChloroBenzene", + nwchem="clbenzen", + mopac="chlorobenzene", + qchem="benzene", + ), + ImplicitSolvent( + name="cyclohexane", + smiles="C1CCCCC1", + aliases=["cyclohexane"], + orca="cyclohexane", + g09="CycloHexane", + nwchem="cychexan", + mopac="cyclohexane", + qchem="cyclohexane", + ), + ImplicitSolvent( + name="1,2-dichlorobenzene", + smiles="ClC1=CC=CC=C1Cl", + aliases=[ + "1,2-dichlorobenzene", + "o-dichlorobenzene", + "ortho-dichlorobenzene", + ], + orca="o-dichlorobenzene", + g09="o-DiChloroBenzene", + nwchem="odiclbnz", + mopac="1,2-dichlorobenzene", + qchem="benzene", + ), + ImplicitSolvent( + name="n,n-dimethylacetamide", + smiles="CC(N(C)C)=O", + aliases=["n,n-dimethylacetamide", "dmac", "dma", "dimethylacetamide"], + orca="n,n-dimethylacetamide", + g09="n,n-DiMethylAcetamide", + nwchem="dma", + mopac="n,n-dimethylacetamide", + qchem="dimethylacetamide", + ), + ImplicitSolvent( + name="dioxane", + smiles="O1CCOCC1", + aliases=["dioxane", "1,4-dioxane", "p-dioxane"], + orca="1,4-dioxane", + g09="1,4-Dioxane", + nwchem="dioxane", + mopac="1,4-dioxane", + qchem="1,4-dioxane", + ), + ImplicitSolvent( + name="ethyl acetate", + smiles="CC(OCC)=O", + aliases=["ethyl acetate", "etoac", "ethyl ethanoate"], + orca="ethyl ethanoate", + g09="EthylEthanoate", + nwchem="etoac", + mopac="ethyl acetate", + ), + ImplicitSolvent( + name="ethanol", + smiles="CCO", + aliases=["ethanol", "ethyl alcohol", "etoh"], + orca="ethanol", + g09="Ethanol", + nwchem="ethanol", + mopac="ethyl alcohol", + qchem="ethanol", + ), + ImplicitSolvent( + name="heptane", + smiles="CCCCCCC", + aliases=["heptane", "n-heptane"], + orca="n-heptane", + g09="Heptane", + nwchem="heptane", + mopac="heptane", + qchem="heptane", + ), + ImplicitSolvent( + name="pentane", + smiles="CCCCC", + aliases=["pentane", "n-pentane"], + orca="n-pentane", + g09="n-Pentane", + nwchem="npentane", + mopac="pentane", + qchem="pentane", + ), + ImplicitSolvent( + name="1-propanol", + smiles="CCCO", + aliases=["1-propanol", "propanol", "n-propaol", "n-proh"], + orca="1-propanol", + g09="1-Propanol", + nwchem="propanol", + mopac="1-propanol", + qchem="1-propanol", + ), + ImplicitSolvent( + name="pyridine", + smiles="C1=NC=CC=C1", + aliases=["pyridine"], + orca="pyridine", + g09="Pyridine", + nwchem="pyridine", + mopac="pyridine", + qchem="pyridine", + ), + ImplicitSolvent( + name="1,1,1-trichloroethane", + smiles="CC(Cl)(Cl)Cl", + aliases=["1,1,1-trichloroethane", "methyl chloroform", "1,1,1-tca"], + orca="1,1,1-trichloroethane", + g09="1,1,1-TriChloroEthane", + nwchem="tca111", + mopac="1,1,1-trichloroethane", + qchem="1,1,1-trichloroethane", + ), + ImplicitSolvent( + name="cyclopentane", + smiles="C1CCCC1", + aliases=["cyclopentane"], + orca="cyclopentane", + g09="CycloPentane", + nwchem="cycpentn", + mopac="cyclopentane", + qchem="cyclopentane", + ), + ImplicitSolvent( + name="1,1,2-trichloroethane", + smiles="ClCC(Cl)Cl", + aliases=["1,1,2-trichloroethane", "vinyl trichloride", "1,1,2-tca"], + orca="1,1,2-trichloroethane", + g09="1,1,2-TriChloroEthane", + nwchem="tca112", + mopac="1,1,2-trichloroethane", + qchem="1,1,2-trichloroethane", + ), + ImplicitSolvent( + name="cyclopentanol", + smiles="OC1CCCC1", + aliases=["cyclopentanol"], + orca="cyclopentanol", + g09="CycloPentanol", + nwchem="cycpntol", + mopac="cyclopentanol", + qchem="cyclopentanol", + ), + ImplicitSolvent( + name="1,2,4-trimethylbenzene", + smiles="CC1=CC=C(C)C(C)=C1", + aliases=["1,2,4-trimethylbenzene", "pseudocumene"], + orca="1,2,4-trimethylbenzene", + g09="1,2,4-TriMethylBenzene", + nwchem="tmben124", + mopac="1,2,4-trimethylbenzene", + qchem="1,2,4-trimethylbenzene", + ), + ImplicitSolvent( + name="cyclopentanone", + smiles="O=C1CCCC1", + aliases=["cyclopentanone"], + orca="cyclopentanone", + g09="CycloPentanone", + nwchem="cycpnton", + mopac="cyclopentanone", + qchem="cyclopentanone", + ), + ImplicitSolvent( + name="1,2-dibromoethane", + smiles="BrCCBr", + aliases=["1,2-dibromoethane", "ethylene dibromide", "edb"], + orca="1,2-dibromoethane", + g09="1,2-DiBromoEthane", + nwchem="edb12", + mopac="1,2-dibromoethane", + qchem="bromoethane", + ), + ImplicitSolvent( + name="1,2-dichloroethane", + smiles="ClCCCl", + aliases=[ + "1,2-dichloroethane", + "ethylene dichloride", + "dce", + "dichloroethane", + ], + orca="1,2-dichloroethane", + g09="DiChloroEthane", + nwchem="edc12", + mopac="1,2-dichloroethane", + ), + ImplicitSolvent( + name="cis-decalin", + smiles="[H][C@@]12CCCC[C@]1([H])CCCC2", + aliases=["cis-decalin", "cis decalin"], + orca="cis-decalin", + g09="Cis-Decalin", + nwchem="declncis", + mopac="cis-decalin", + qchem="decalin", + ), + ImplicitSolvent( + name="trans-decalin", + smiles="[H][C@@]12CCCC[C@@]1([H])CCCC2", + aliases=["trans-decalin", "trans decalin"], + orca="trans-decalin", + g09="trans-Decalin", + nwchem="declntra", + mopac="trans-decalin", + qchem="decalin", + ), + ImplicitSolvent( + name="decalin mix", + smiles="C12CCCCC1CCCC2", + aliases=["decalin mix", "decalin", "decalin mixture"], + orca="decalin", + g09="Decalin-mixture", + nwchem="declnmix", + mopac="decalin", + qchem="decalin", + ), + ImplicitSolvent( + name="1,2-ethanediol", + smiles="OCCO", + aliases=[ + "1,2-ethanediol", + "ethylene glycol", + "ethane-1,2-diol", + "monoethylene glycol", + ], + orca="1,2-ethanediol", + g09="1,2-EthaneDiol", + nwchem="meg", + mopac="1,2-ethanediol", + qchem="ethylene glycol", + ), + ImplicitSolvent( + name="decane", + smiles="CCCCCCCCCC", + aliases=["decane", "n-decane"], + orca="n-decane", + g09="n-Decane", + nwchem="decane", + mopac="decane", + qchem="decane", + ), + ImplicitSolvent( + name="dibromomethane", + smiles="BrCBr", + aliases=["dibromomethane", "methyl dibromide"], + orca="dibromomethane", + g09="DiBromomEthane", + nwchem="dibrmetn", + mopac="dibromomethane", + qchem="dibromomethane", + ), + ImplicitSolvent( + name="dibutylether", + smiles="CCCCOCCCC", + aliases=["dibutylether", "butyl ether"], + orca="dibutylether", + g09="DiButylEther", + nwchem="butyleth", + mopac="dibutylether", + ), + ImplicitSolvent( + name="cis-1,2-dichloroethene", + smiles="Cl/C=C\\Cl", + aliases=[ + "cis-1,2-dichloroethene", + "cis-1,2-dichloroethylene", + "z-1,2-dichloroethene", + "z-1,2-dichloroethylene", + ], + orca="z-1,2-dichloroethene", + g09="z-1,2-DiChloroEthene", + nwchem="c12dce", + mopac="z-1,2-dichloroethene", + qchem="z-1,2-dichloroethene", + ), + ImplicitSolvent( + name="trans-1,2-dichloroethen", + smiles="Cl/C=C/Cl", + aliases=[ + "trans-1,2-dichloroethene", + "trans-1,2-dichloroethylene", + "e-1,2-dichloroethene", + "e-1,2-dichloroethylene", + ], + orca="e-1,2-dichloroethene", + g09="e-1,2-DiChloroEthene", + nwchem="t12dce", + mopac="z-1,2-dichloroethene", + qchem="E-1,2-dichloroethene", + ), + ImplicitSolvent( + name="1-bromopropane", + smiles="CCCBr", + aliases=["1-bromopropane", "bromopropane"], + orca="1-bromopropane", + g09="1-BromoPropane", + nwchem="brpropan", + mopac="1-bromopropane", + qchem="1-bromopropane", + ), + ImplicitSolvent( + name="2-bromopropane", + smiles="CC(Br)C", + aliases=["2-bromopropane", "isopropyl bromide"], + orca="2-bromopropane", + g09="2-BromoPropane", + nwchem="brpropa2", + mopac="2-bromopropane", + qchem="2-bromopropane", + ), + ImplicitSolvent( + name="1-chlorohexane", + smiles="CCCCCCCl", + aliases=["1-chlorohexane", "chlorohexane"], + orca="1-chlorohexane", + g09="1-ChloroHexane", + nwchem="clhexane", + mopac="1-chlorohexane", + qchem="hexane", + ), + ImplicitSolvent( + name="1-chloropentane", + smiles="CCCCCCl", + aliases=["1-chloropentane", "chloropentane"], + orca="1-chloropentane", + g09="1-ChloroPentane", + nwchem="clpentan", + mopac="1-chloropentane", + qchem="1-chloropentane", + ), + ImplicitSolvent( + name="1-chloropropane", + smiles="CCCCl", + aliases=["1-chloropropane", "chloropropane"], + orca="1-chloropropane", + g09="1-ChloroPropane", + nwchem="clpropan", + mopac="1-chloropropane", + qchem="1-chloropropane", + ), + ImplicitSolvent( + name="diethylamine", + smiles="CCNCC", + aliases=["diethylamine", "n-ethylethanamine"], + orca="diethylamine", + g09="DiEthylAmine", + nwchem="dietamin", + mopac="diethylamine", + qchem="diethylamine", + ), + ImplicitSolvent( + name="1-decanol", + smiles="CCCCCCCCCCO", + aliases=["1-decanol", "decanol", "decan-1-ol"], + orca="1-decanol", + g09="1-Decanol", + nwchem="decanol", + mopac="decanol", + qchem="1-decanol", + ), + ImplicitSolvent( + name="diiodomethane", + smiles="ICI", + aliases=["diiodomethane", "methylene iodide"], + orca="diiodomethane", + g09="DiIodoMethane", + nwchem="mi", + mopac="diiodomethane", + qchem="diiodomethane", + ), + ImplicitSolvent( + name="1-fluorooctane", + smiles="CCCCCCCCF", + aliases=["1-fluorooctane", "fluorooctane", "octyl fluoride"], + orca="1-fluorooctane", + g09="1-FluoroOctane", + nwchem="foctane", + mopac="1-fluorooctane", + qchem="1-fluorooctane", + ), + ImplicitSolvent( + name="1-heptanol", + smiles="CCCCCCCO", + aliases=["1-helptanol", "heptanol", "heptan-1-ol"], + orca="1-helptanol", + g09="1-Heptanol", + nwchem="heptanol", + mopac="heptanol", + qchem="1-heptanol", + ), + ImplicitSolvent( + name="cis-1,2-dimethylcyclohexane", + smiles="C[C@@H]1[C@H](C)CCCC1", + aliases=["cis-1,2-dimethylcyclohexane"], + orca="cis-1,2-dimethylcyclohexane", + g09="Cis-1,2-DiMethylCycloHexane", + nwchem="cisdmchx", + mopac="cisdmchx", + qchem="cis-1,2-dimethylcyclohexane", + ), + ImplicitSolvent( + name="diethyl sulfide", + smiles="CCSCC", + aliases=["diethyl sulfide", "et2s", "thioethyl ether"], + orca="diethyl sulfide", + g09="DiEthylSulfide", + nwchem="et2s", + mopac="diethyl sulfide", + ), + ImplicitSolvent( + name="diisopropyl ether", + smiles="CC(OC(C)C)C", + aliases=["diisopropyl ether", "dipe"], + orca="diisopropyl ether", + g09="DiIsoPropylEther", + nwchem="dipe", + mopac="diisopropyl ether", + qchem="isopropyl ether", + ), + ImplicitSolvent( + name="1-hexanol", + smiles="CCCCCCO", + aliases=["1-hexanol", "hexanol", "haxan-1-ol"], + orca="1-hexanol", + g09="1-Hexanol", + nwchem="hexanol", + mopac="hexanol", + qchem="1-hexanol", + ), + ImplicitSolvent( + name="1-hexene", + smiles="C=CCCCC", + aliases=["1-hexene", "hexene", "hex-1-ene"], + orca="1-hexene", + g09="1-Hexene", + nwchem="hexene", + mopac="hexene", + qchem="1-hexene", + ), + ImplicitSolvent( + name="1-hexyne", + smiles="C#CCCCC", + aliases=["1-hexyne", "hexyne", "hex-1-yne"], + orca="1-hexyne", + g09="1-Hexyne", + nwchem="hexyne", + mopac="hexyne", + qchem="1-hexyne", + ), + ImplicitSolvent( + name="1-iodobutane", + smiles="CCCCI", + aliases=["1-iodobutane", "iodobutane"], + orca="1-iodobutane", + g09="1-IodoButane", + nwchem="iobutane", + mopac="iodobutane", + qchem="1-iodobutane", + ), + ImplicitSolvent( + name="1-iodohexadecane", + smiles="CCCCCCCCCCCCCCCCI", + aliases=["1-iodohexadecane", "iodohexadecane"], + orca="1-iodohexadecane", + g09="1-IodoHexaDecane", + nwchem="iohexdec", + mopac="1-iodohexadecane", + qchem="decane", + ), + ImplicitSolvent( + name="diphenylether", + smiles="C1(OC2=CC=CC=C2)=CC=CC=C1", + aliases=["diphenylether", "phenoxybenzene"], + orca="diphenylether", + g09="DiPhenylEther", + nwchem="phoph", + mopac="diphenylether", + qchem="benzene", + ), + ImplicitSolvent( + name="1-iodopentane", + smiles="CCCCCI", + aliases=["1-iodopentane", "iodopentane"], + orca="1-iodopentane", + g09="1-IodoPentane", + nwchem="iopentan", + mopac="1-iodopentane", + qchem="pentane", + ), + ImplicitSolvent( + name="1-iodopropane", + smiles="CCCI", + aliases=["1-iodopropane", "iodopropane"], + orca="1-iodopropane", + g09="1-IodoPropane", + nwchem="iopropan", + mopac="1-iodopropane", + qchem="1-iodopropane", + ), + ImplicitSolvent( + name="dipropylamine", + smiles="CCCNCCC", + aliases=["dipropylamine"], + orca="dipropylamine", + g09="DiPropylAmine", + nwchem="dproamin", + mopac="dipropylamine", + qchem="dipropylamine", + ), + ImplicitSolvent( + name="n-dodecane", + smiles="CCCCCCCCCCCC", + aliases=["n-dodecane", "dodecane"], + orca="n-dodecane", + g09="n-Dodecane", + nwchem="dodecan", + mopac="dodecane", + qchem="decane", + ), + ImplicitSolvent( + name="1-nitropropane", + smiles="CCC[N+]([O-])=O", + aliases=["1-nitropropane"], + orca="1-nitropropane", + g09="1-NitroPropane", + nwchem="ntrprop1", + mopac="1-nitropropane", + qchem="1-nitropropane", + ), + ImplicitSolvent( + name="ethanethiol", + smiles="CCS", + aliases=["ethanethiol", "ethane thiol", "etsh"], + orca="ethanethiol", + g09="EthaneThiol", + nwchem="etsh", + mopac="ethanethiol", + qchem="ethanethiol", + ), + ImplicitSolvent( + name="1-nonanol", + smiles="CCCCCCCCCO", + aliases=["1-nonanol", "nonanol", "nonan-1-ol"], + orca="1-nonanol", + g09="1-Nonanol", + nwchem="nonanol", + mopac="nonanol", + qchem="1-nonanol", + ), + ImplicitSolvent( + name="1-octanol", + smiles="CCCCCCCCO", + aliases=["1-octanol", "octanol", "octan-1-ol"], + orca="1-octanol", + g09="n-Octanol", + nwchem="octanol", + mopac="octanol", + qchem="1-octanol", + ), + ImplicitSolvent( + name="1-pentanol", + smiles="CCCCCO", + aliases=["1-pentanol", "pentanol", "pentan-1-ol"], + orca="1-pentanol", + g09="1-Pentanol", + nwchem="pentanol", + mopac="pentanol", + qchem="1-pentanol", + ), + ImplicitSolvent( + name="1-pentene", + smiles="C=CCCC", + aliases=["1-pentene", "pentene", "pent-1-ene"], + orca="1-pentene", + g09="1-Pentene", + nwchem="pentene", + mopac="pentene", + qchem="1-pentene", + ), + ImplicitSolvent( + name="ethyl benzene", + smiles="CCC1=CC=CC=C1", + aliases=["ethyl benzene", "ethylbenzene", "phenylethane"], + orca="ethylbenzene", + g09="EthylBenzene", + nwchem="eb", + mopac="ethylbenzene", + qchem="benzene", + ), + ImplicitSolvent( + name="2,2,2-trifluoroethanol", + smiles="FC(F)(F)CO", + aliases=["2,2,2-trifluoroethanol"], + orca="2,2,2-trifluoroethanol", + g09="2,2,2-TriFluoroEthanol", + nwchem="tfe222", + mopac="2,2,2-trifluoroethanol", + qchem="ethanol", + ), + ImplicitSolvent( + name="fluorobenzene", + smiles="FC1=CC=CC=C1", + aliases=["fluorobenzene", "phenyl fluoride", "c6h5f"], + orca="fluorobenzene", + g09="FluoroBenzene", + nwchem="c6h5f", + mopac="fluorobenzene", + qchem="benzene", + ), + ImplicitSolvent( + name="2,2,4-trimethylpentane", + smiles="CC(C)(C)CC(C)C", + aliases=["2,2,4-trimethylpentane", "isooctane"], + orca="2,2,4-trimethylpentane", + g09="2,2,4-TriMethylPentane", + nwchem="isoctane", + mopac="2,2,4-trimethylpentane", + qchem="2,2,4-trimethylpentane", + ), + ImplicitSolvent( + name="formamide", + smiles="O=CN", + aliases=["formamide"], + orca="formamide", + g09="Formamide", + nwchem="formamid", + mopac="formamide", + qchem="formamide", + ), + ImplicitSolvent( + name="2,4-dimethylpentane", + smiles="CC(C)CC(C)C", + aliases=["2,4-dimethylpentane", "diisopropylmethane"], + orca="2,4-dimethylpentane", + g09="2,4-DiMethylPentane", + nwchem="dmepen24", + mopac="2,4-dimethylpentane", + qchem="2,4-dimethylpentane", + ), + ImplicitSolvent( + name="2,4-dimethylpyridine", + smiles="CC1=CC(C)=NC=C1", + aliases=["2,4-dimethylpyridine", "2,4-lutidine"], + orca="2,4-dimethylpyridine", + g09="2,4-DiMethylPyridine", + nwchem="dmepyr24", + mopac="2,4-dimethylpyridine", + qchem="2,4-dimethylpyridine", + ), + ImplicitSolvent( + name="2,6-dimethylpyridine", + smiles="CC1=CC=CC(C)=N1", + aliases=["2,6-dimethylpyridine", "2,6-lutidine", "lutidine"], + orca="2,6-dimethylpyridine", + g09="2,6-DiMethylPyridine", + nwchem="dmepyr26", + mopac="2,6-dimethylpyridine", + qchem="2,6-dimethylpyridine", + ), + ImplicitSolvent( + name="n-hexadecane", + smiles="CCCCCCCCCCCCCCCC", + aliases=["n-hexadecane", "hexadecane"], + orca="n-hexadecane", + g09="n-Hexadecane", + nwchem="hexadecn", + mopac="hexadecane", + qchem="decane", + ), + ImplicitSolvent( + name="dimethyl disulfide", + smiles="CSSC", + aliases=["dimethyl disulfide", "dmds", "methyl disulfide"], + orca="dimethyl disulfide", + g09="DiMethylDiSulfide", + nwchem="dmds", + mopac="dimethyl disulfide", + ), + ImplicitSolvent( + name="ethyl methanoate", + smiles="O=COCC", + aliases=["ethyl methanoate", "ethyl formate", "etome"], + orca="ethyl methanoate", + g09="EthylMethanoate", + nwchem="etome", + mopac="ethyl methanoate", + ), + ImplicitSolvent( + name="ethyl phenyl ether", + smiles="CCOC1=CC=CC=C1", + aliases=["ethyl phenyl ether", "phenetole", "ethoxybenzene"], + orca="ethyl phenyl ether", + g09="EthylPhenylEther", + nwchem="phentol", + mopac="phenetole", + qchem="benzene", + ), + ImplicitSolvent( + name="formic acid", + smiles="O=CO", + aliases=["formic acid", "methanoic acid"], + orca="formic acid", + g09="FormicAcid", + nwchem="formacid", + mopac="formic acid", + qchem="formic acid", + ), + ImplicitSolvent( + name="hexanoic acid", + smiles="CCCCCC(O)=O", + aliases=["hexanoic acid", "caproic acid"], + orca="hexanoic acid", + g09="HexanoicAcid", + nwchem="hexnacid", + mopac="hexanoic acid", + qchem="hexanoic acid", + ), + ImplicitSolvent( + name="2-chlorobutane", + smiles="CC(Cl)CC", + aliases=["2-chlorobutane", "sec-butyl chloride"], + orca="2-chlorobutane", + g09="2-ChloroButane", + nwchem="secbutcl", + mopac="2-chlorobutane", + qchem="2-chlorobutane", + ), + ImplicitSolvent( + name="2-heptanone", + smiles="CC(CCCCC)=O", + aliases=["2-heptanone", "heptan-2-one"], + orca="2-heptanone", + g09="2-Heptanone", + nwchem="heptnon2", + mopac="2-heptanone", + qchem="2-heptanone", + ), + ImplicitSolvent( + name="2-hexanone", + smiles="CC(CCCC)=O", + aliases=["2-hexanone", "hexan-2-one"], + orca="2-hexanone", + g09="2-Hexanone", + nwchem="hexanon2", + mopac="2-hexanone", + qchem="2-hexanone", + ), + ImplicitSolvent( + name="2-methoxyethanol", + smiles="COCCO", + aliases=["2-methoxyethanol", "egme"], + orca="2-methoxyethanol", + g09="2-MethoxyEthanol", + nwchem="egme", + mopac="2-methoxyethanol", + qchem="ethanol", + ), + ImplicitSolvent( + name="2-methyl-1-propanol", + smiles="CC(C)CO", + aliases=["2-methyl-1-propanol", "isobutanol"], + orca="2-methyl-1-propanol", + g09="2-Methyl-1-Propanol", + nwchem="isobutol", + mopac="isobutanol", + qchem="1-propanol", + ), + ImplicitSolvent( + name="2-methyl-2-propanol", + smiles="CC(O)(C)C", + aliases=["2-methyl-2-propanol", "tert-butanol"], + orca="2-methyl-2-propanol", + g09="2-Methyl-2-Propanol", + nwchem="terbutol", + mopac="tertbutanol", + qchem="2-propanol", + ), + ImplicitSolvent( + name="2-methylpentane", + smiles="CC(C)CCC", + aliases=["2-methylpentane", "isohexane"], + orca="2-methylpentane", + g09="2-MethylPentane", + nwchem="isohexan", + mopac="2-methylpentane", + qchem="2-methylpentane", + ), + ImplicitSolvent( + name="2-methylpyridine", + smiles="CC1=NC=CC=C1", + aliases=["2-methylpyridine", "2-picoline"], + orca="2-methylpyridine", + g09="2-MethylPyridine", + nwchem="mepyrid2", + mopac="2-methylpyridine", + qchem="2-methylpyridine", + ), + ImplicitSolvent( + name="2-nitropropane", + smiles="CC([N+]([O-])=O)C", + aliases=["2-nitropropane"], + orca="2-nitropropane", + g09="2-NitroPropane", + nwchem="ntrprop2", + mopac="2-nitropropane", + qchem="2-nitropropane", + ), + ImplicitSolvent( + name="2-octanone", + smiles="CC(CCCCCC)=O", + aliases=["2-octanone", "octan-2-one"], + orca="2-octanone", + g09="2-Octanone", + nwchem="octanon2", + mopac="2-octanone", + qchem="2-octanone", + ), + ImplicitSolvent( + name="2-pentanone", + smiles="CC(CCC)=O", + aliases=["2-pentanone", "pentan-2-one"], + orca="2-pentanone", + g09="2-Pentanone", + nwchem="pentnon2", + mopac="2-pentanone", + qchem="2-pentanone", + ), + ImplicitSolvent( + name="iodobenzene", + smiles="IC1=CC=CC=C1", + aliases=["iodobenzene", "phenyl iodide"], + orca="iodobenzene", + g09="IodoBenzene", + nwchem="c6h5i", + mopac="iodobenzene", + qchem="benzene", + ), + ImplicitSolvent( + name="iodoethane", + smiles="CCI", + aliases=["iodoethane", "ethyl iodide"], + orca="iodoethane", + g09="IodoEthane", + nwchem="c2h5i", + mopac="iodoethane", + qchem="iodoethane", + ), + ImplicitSolvent( + name="iodomethane", + smiles="CI", + aliases=["iodomethane", "methyl iodide", "mei", "ch3i"], + orca="iodomethane", + g09="IodoMethane", + nwchem="ch3i", + mopac="iodomethane", + qchem="iodomethane", + ), + ImplicitSolvent( + name="isopropylbenzene", + smiles="CC(C1=CC=CC=C1)C", + aliases=["isopropylbenzene", "cumene"], + orca="isopropylbenzene", + g09="IsoPropylBenzene", + nwchem="cumene", + mopac="isopropylbenzene", + qchem="benzene", + ), + ImplicitSolvent( + name="p-isopropyltoluene", + smiles="CC1=CC=C(C(C)C)C=C1", + aliases=["p-isopropyltoluene", "para-isopropyltoluene", "p-cymene"], + orca="p-isopropyltoluene", + g09="p-IsoPropylToluene", + nwchem="p-cymene", + mopac="p-cymene", + qchem="isopropyltoluene", + ), + ImplicitSolvent( + name="mesitylene", + smiles="CC1=CC(C)=CC(C)=C1", + aliases=["mesitylene"], + orca="mesitylene", + g09="Mesitylene", + nwchem="mesityln", + mopac="mesitylene", + qchem="mesitylene", + ), + ImplicitSolvent( + name="methyl benzoate", + smiles="O=C(OC)C1=CC=CC=C1", + aliases=["methyl benzoate"], + orca="methyl benzoate", + g09="MethylBenzoate", + nwchem="mebnzate", + mopac="methyl benzoate", + ), + ImplicitSolvent( + name="methyl butanoate", + smiles="CCCC(OC)=O", + aliases=["methyl butanoate", "methyl butyrate"], + orca="methyl butanoate", + g09="MethylButanoate", + nwchem="mebutate", + mopac="methyl butanoate", + ), + ImplicitSolvent( + name="methyl ethanoate", + smiles="CC(OC)=O", + aliases=["methyl ethanoate", "methyl acetate"], + orca="methyl ethanoate", + g09="MethylEthanoate", + nwchem="meacetat", + mopac="methyl acetate", + ), + ImplicitSolvent( + name="methyl methanoate", + smiles="O=COC", + aliases=["methyl methanoate", "methyl formate"], + orca="methyl methanoate", + g09="MethylMethanoate", + nwchem="meformat", + mopac="methyl formate", + ), + ImplicitSolvent( + name="methyl propanoate", + smiles="CCC(OC)=O", + aliases=["methyl propanoate", "methyl propionate"], + orca="methyl propanoate", + g09="MethylPropanoate", + nwchem="mepropyl", + mopac="methyl propanoate", + ), + ImplicitSolvent( + name="n-methylaniline", + smiles="CNC1=CC=CC=C1", + aliases=["n-methylaniline", "nma"], + orca="n-methylaniline", + g09="n-MethylAniline", + nwchem="nmeaniln", + mopac="n-methylaniline", + qchem="aniline", + ), + ImplicitSolvent( + name="methylcyclohexane", + smiles="CC1CCCCC1", + aliases=["methylcyclohexane"], + orca="methylcyclohexane", + g09="MethylCycloHexane", + nwchem="mecychex", + mopac="methylcyclohexane", + qchem="cyclohexane", + ), + ImplicitSolvent( + name="n-methylformamide (e/z mixture)", + smiles="O=CNC", + aliases=[ + "n-methylformamide", + "n-methylformamide (e/z mixture)", + "n-methylformamide mixture", + "n-methylformamide mix", + ], + orca="n-methylformamide (e/z mixture)", + g09="n-MethylFormamide-mixture", + nwchem="nmfmixtr", + mopac="nmfmixtr", + qchem="formamide", + ), + ImplicitSolvent( + name="nitrobenzene", + smiles="O=[N+](C1=CC=CC=C1)[O-]", + aliases=["nitrobenzene", "phno2"], + orca="nitrobenzene", + g09="NitroBenzene", + nwchem="c6h5no2", + mopac="nitrobenzene", + qchem="benzene", + ), + ImplicitSolvent( + name="nitroethane", + smiles="CC[N+]([O-])=O", + aliases=["nitroethane", "etno2"], + orca="nitroethane", + g09="NitroEthane", + nwchem="c2h5no2", + mopac="nitroethane", + qchem="nitroethane", + ), + ImplicitSolvent( + name="nitromethane", + smiles="C[N+]([O-])=O", + aliases=["nitromethane", "meno2", "ch3no2"], + orca="nitromethane", + g09="NitroMethane", + nwchem="ch3no2", + mopac="nitromethane", + qchem="nitromethane", + ), + ImplicitSolvent( + name="o-nitrotoluene", + smiles="CC1=CC=CC=C1[N+]([O-])=O", + aliases=["o-nitrotoluene", "ortho-nitrotoluene"], + orca="o-nitrotoluene", + g09="o-NitroToluene", + nwchem="ontrtolu", + mopac="o-nitrotoluene", + qchem="o-nitrotoluene", + ), + ImplicitSolvent( + name="n-nonane", + smiles="CCCCCCCCC", + aliases=["n-nonane", "nonane"], + orca="n-nonane", + g09="n-Nonane", + nwchem="nonane", + mopac="n-nonane", + qchem="nonane", + ), + ImplicitSolvent( + name="n-octane", + smiles="CCCCCCCC", + aliases=["n-octane", "octane"], + orca="n-octane", + g09="n-Octane", + nwchem="octane", + mopac="n-octane", + qchem="octane", + ), + ImplicitSolvent( + name="n-pentadecane", + smiles="CCCCCCCCCCCCCCC", + aliases=["n-pentadecane", "pentadecane"], + orca="n-pentadecane", + g09="n-Pentadecane", + nwchem="pentdecn", + mopac="n-pentadecane", + qchem="decane", + ), + ImplicitSolvent( + name="pentanal", + smiles="CCCCC=O", + aliases=["pentanal"], + orca="pentanal", + g09="Pentanal", + nwchem="pentanal", + mopac="pentanal", + qchem="pentanal", + ), + ImplicitSolvent( + name="pentanoic acid", + smiles="CCCCC(O)=O", + aliases=["pentanoic acid", "valeric acid"], + orca="pentanoic acid", + g09="PentanoicAcid", + nwchem="pentacid", + mopac="pentanoic acid", + qchem="pentanoic acid", + ), + ImplicitSolvent( + name="pentyl ethanoate", + smiles="CC(OCCCCC)=O", + aliases=["pentyl ethanoate", "pentyl acetate"], + orca="pentyl ethanoate", + g09="PentylEthanoate", + nwchem="pentacet", + mopac="pentyl acetate", + ), + ImplicitSolvent( + name="pentyl amine", + smiles="NCCCCC", + aliases=["pentyl amine", "pentylamine", "1-aminopentane"], + orca="pentylamine", + g09="PentylAmine", + nwchem="pentamin", + mopac="pentylamine", + qchem="pentane", + ), + ImplicitSolvent( + name="perfluorobenzene", + smiles="FC1=C(F)C(F)=C(F)C(F)=C1F", + aliases=["perfluorobenzene", "pfb", "c6f6", "hexafluorobenzene"], + orca="perfluorobenzene", + g09="PerFluoroBenzene", + nwchem="pfb", + mopac="perfluorobenzene", + qchem="benzene", + ), + ImplicitSolvent( + name="propanal", + smiles="CCC=O", + aliases=["propanal"], + orca="propanal", + g09="Propanal", + nwchem="propanal", + mopac="propanal", + qchem="propanal", + ), + ImplicitSolvent( + name="propanoic acid", + smiles="CCC(O)=O", + aliases=["propanoic acid", "propionic acid"], + orca="propanoic acid", + g09="PropanoicAcid", + nwchem="propacid", + mopac="propanoic acid", + qchem="propanoic acid", + ), + ImplicitSolvent( + name="propanenitrile", + smiles="CCC#N", + aliases=[ + "propanenitrile", + "cyanoethane", + "ethyl cyanide", + "propanonitrile", + ], + orca="propanonitrile", + g09="PropanoNitrile", + nwchem="propntrl", + mopac="cyanoethane", + qchem="propanonitrile", + ), + ImplicitSolvent( + name="propyl ethanoate", + smiles="CC(OCCC)=O", + aliases=["propyl ethanoate", "propyl acetate"], + orca="propyl ethanoate", + g09="PropylEthanoate", + nwchem="propacet", + mopac="propyl acetate", + ), + ImplicitSolvent( + name="propyl amine", + smiles="NCCC", + aliases=["propyl amine", "propylamine", "1-aminopropane"], + orca="propylamine", + g09="PropylAmine", + nwchem="propamin", + mopac="propylamine", + qchem="propylamine", + ), + ImplicitSolvent( + name="tetrachloroethene", + smiles="Cl/C(Cl)=C(Cl)/Cl", + aliases=["tetrachloroethene", "perchloroethene", "pce", "c2cl4"], + orca="tetrachloroethene", + g09="TetraChloroEthene", + nwchem="c2cl4", + mopac="tetrachloroethene", + qchem="tetrachloroethene", + ), + ImplicitSolvent( + name="tetrahydrothiophene-s,s-dioxide", + smiles="O=S1(CCCC1)=O", + aliases=["tetrahydrothiophene-s,s-dioxide", "sulfolane"], + orca="tetrahydrothiophene-s,s-dioxide", + g09="TetraHydroThiophene-s,s-dioxide", + nwchem="sulfolan", + mopac="sulfolane", + qchem="thiophene", + ), + ImplicitSolvent( + name="tetralin", + smiles="C12=C(CCCC2)C=CC=C1", + aliases=[ + "tetralin", + "1,2,3,4-tetrahydronaphthalene", + "tetrahydronaphthalene", + ], + orca="tetralin", + g09="Tetralin", + nwchem="tetralin", + mopac="tetralin", + qchem="tetralin", + ), + ImplicitSolvent( + name="thiophene", + smiles="C1=CC=CS1", + aliases=["thiophene"], + orca="thiophene", + g09="Thiophene", + nwchem="thiophen", + mopac="thiophene", + qchem="thiophene", + ), + ImplicitSolvent( + name="thiophenol", + smiles="SC1=CC=CC=C1", + aliases=["thiophenol", "phsh", "benzenethiol"], + orca="thiophenol", + g09="Thiophenol", + nwchem="phsh", + mopac="thiophenol", + qchem="benzene", + ), + ImplicitSolvent( + name="tributylphosphate", + smiles="O=P(OCCCC)(OCCCC)OCCCC", + aliases=["tributylphopshate", "tbp", "tributyl phopshate"], + orca="tributylphopshate", + g09="TriButylPhosphate", + nwchem="tbp", + mopac="tbp", + qchem="tributylphosphate", + ), + ImplicitSolvent( + name="trichloroethene", + smiles="Cl/C(Cl)=C/Cl", + aliases=["trichloroethene", "tce"], + orca="trichloroethene", + g09="TriChloroEthene", + nwchem="tce", + mopac="tce", + qchem="trichloroethene", + ), + ImplicitSolvent( + name="triethylamine", + smiles="CCN(CC)CC", + aliases=["triethylamine", "et3n"], + orca="triethylamine", + g09="TriEthylAmine", + nwchem="et3n", + mopac="triethylamine", + qchem="triethylamine", + ), + ImplicitSolvent( + name="n-undecane", + smiles="CCCCCCCCCCC", + aliases=["n-undecane", "undecane"], + orca="n-undecane", + g09="n-Undecane", + nwchem="undecane", + mopac="n-undecane", + qchem="decane", + ), + ImplicitSolvent( + name="xylene mixture", + smiles="CC1=CC=C(C)C=C1", + aliases=[ + "xylene mix", + "xylene (mix)", + "xylene mixture", + "xylene (mixture)", + "xylene", + ], + orca="xyzlene (mixture)", + g09="Xylene-mixture", + nwchem="xylenemx", + mopac="xylene mix", + ), + ImplicitSolvent( + name="m-xylene", + smiles="CC1=CC=CC(C)=C1", + aliases=["m-xylene", "meta-xylene", "1,3-xylene"], + orca="m-xylene", + g09="m-Xylene", + nwchem="m-xylene", + mopac="m-xylene", + qchem="m-xylene", + ), + ImplicitSolvent( + name="o-xylene", + smiles="CC1=CC=CC=C1C", + aliases=["o-xylene", "ortho-xylene", "1,2-xylene"], + orca="o-xylene", + g09="o-Xylene", + nwchem="o-xylene", + mopac="o-xylene", + qchem="o-xylene", + ), + ImplicitSolvent( + name="p-xylene", + smiles="CC1=CC=C(C)C=C1", + aliases=["p-xylene", "para-xylene", "1,4-xylene"], + orca="p-xylene", + g09="p-Xylene", + nwchem="p-xylene", + mopac="p-xylene", + qchem="p-xylene", + ), + ImplicitSolvent( + name="2-propanol", + smiles="CC(O)C", + aliases=[ + "2-propanol", + "propan-2-ol", + "isopropanol", + "isopropyl alcohol", + ], + orca="2-propanol", + g09="2-Propanol", + nwchem="propnol2", + mopac="2-propanol", + qchem="2-propanol", + ), + ImplicitSolvent( + name="2-propen-1-ol", + smiles="C=CCO", + aliases=["2-propen-1-ol", "allyl alcohol"], + orca="2-propen-1-ol", + g09="2-Propen-1-ol", + nwchem="propenol", + mopac="2-propen-1-ol", + qchem="2-propen-1-ol", + ), + ImplicitSolvent( + name="e-2-pentene", + smiles="C/C=C/CC", + aliases=["e-2-pentene", "e-pent-2-ene"], + orca="e-2-pentene", + g09="e-2-Pentene", + nwchem="e2penten", + mopac="e-2-pentene", + qchem="E-2-pentene", + ), + ImplicitSolvent( + name="3-methylpyridine", + smiles="CC1=CC=CN=C1", + aliases=["3-methylpyridine", "3-picoline"], + orca="3-methylpyridine", + g09="3-MethylPyridine", + nwchem="mepyrid3", + mopac="3-methylpyridine", + qchem="3-methylpyridine", + ), + ImplicitSolvent( + name="3-pentanone", + smiles="CCC(CC)=O", + aliases=["3-pentanone", "pentan-3-one"], + orca="3-pentanone", + g09="3-Pentanone", + nwchem="pentnon3", + mopac="3-pentanone", + qchem="3-pentanone", + ), + ImplicitSolvent( + name="4-heptanone", + smiles="CCCC(CCC)=O", + aliases=["4-heptanone", "heptan-4-one"], + orca="4-heptanone", + g09="4-Heptanone", + nwchem="heptnon4", + mopac="4-heptanone", + qchem="4-heptanone", + ), + ImplicitSolvent( + name="4-methyl-2-pentanone", + smiles="CC(CC(C)C)=O", + aliases=["4-methyl-2-pentanone", "methyl isobutyl ketone"], + orca="4-methyl-2-pentanone", + g09="4-Methyl-2-Pentanone", + nwchem="mibk", + mopac="mibk", + qchem="2-pentanone", + ), + ImplicitSolvent( + name="4=methylpyridine", + smiles="CC1=CC=NC=C1", + aliases=["4-methylpyridine", "4-picoline"], + orca="4-methylpyridine", + g09="4-MethylPyridine", + nwchem="mepyrid4", + mopac="4-methylpyridine", + qchem="4-methylpyridine", + ), + ImplicitSolvent( + name="5-nonanone", + smiles="CCCCC(CCCC)=O", + aliases=["5-nonanone", "nonan-5-one"], + orca="5-nonanone", + g09="5-Nonanone", + nwchem="nonanone", + mopac="5-nonanone", + qchem="5-nonanone", + ), + ImplicitSolvent( + name="benzyl alcohol", + smiles="OCC1=CC=CC=C1", + aliases=["benzyl alcohol", "phenylmethanol", "bnoh"], + orca="benzyl alcohol", + g09="BenzylAlcohol", + nwchem="benzalcl", + mopac="benzyl alcohol", + qchem="benzyl alcohol", + ), + ImplicitSolvent( + name="butanoic acid", + smiles="CCCC(O)=O", + aliases=["butanoic acid", "butyric acid"], + orca="butanoic acid", + g09="ButanoicAcid", + nwchem="butacid", + mopac="butanoic acid", + ), + ImplicitSolvent( + name="butanenitrile", + smiles="CCCC#N", + aliases=["butanenitrile", "butyronitrile", "butanonitrile"], + orca="butanonitrile", + g09="ButanoNitrile", + nwchem="butantrl", + mopac="butanenitrile", + qchem="butanonitrile", + ), + ImplicitSolvent( + name="butyl ethanoate", + smiles="CC(OCCCC)=O", + aliases=["butyl ethanoate", "butyl acetate"], + orca="butyl ethanoate", + g09="ButylEthanoate", + nwchem="butile", + mopac="butyl acetate", + ), + ImplicitSolvent( + name="butylamine", + smiles="NCCCC", + aliases=["butylamine", "butan-1-amine"], + orca="butylamine", + g09="ButylAmine", + nwchem="nba", + mopac="butylamine", + qchem="butylamine", + ), + ImplicitSolvent( + name="n-butylbenzene", + smiles="CCCCC1=CC=CC=C1", + aliases=["n-butylbenzene", "butylbenzene", "phenylbutane"], + orca="n-butylbenzene", + g09="n-ButylBenzene", + nwchem="nbutbenz", + mopac="n-butylbenzene", + qchem="benzene", + ), + ImplicitSolvent( + name="sec-butylbenzene", + smiles="CCC(C1=CC=CC=C1)C", + aliases=["sec-butylbenzene", "s-butylbenzene"], + orca="sec-butylbenzene", + g09="sec-ButylBenzene", + nwchem="sbutbenz", + mopac="s-butylbenzene", + qchem="benzene", + ), + ImplicitSolvent( + name="tert-butylbenzene", + smiles="CC(C1=CC=CC=C1)(C)C", + aliases=["tert-butylbenzene", "t-butylbenzene"], + orca="tert-butylbenzene", + g09="tert-ButylBenzene", + nwchem="tbutbenz", + mopac="t-butylbenzene", + qchem="benzene", + ), + ImplicitSolvent( + name="o-chlorotoluene", + smiles="CC1=CC=CC=C1Cl", + aliases=["o-chlorotoluene", "ortho-chlorotoluene", "2-chlorotoluene"], + orca="o-chlorotoluene", + g09="o-ChloroToluene", + nwchem="ocltolue", + mopac="o-chlorotoluene", + qchem="chlorotoluene", + ), + ImplicitSolvent( + name="m-cresol", + smiles="CC1=CC(O)=CC=C1", + aliases=["m-cresol", "meta-cresol", "3-methylphenol"], + orca="m-cresol", + g09="m-Cresol", + nwchem="m-cresol", + mopac="m-cresol", + qchem="m-cresol", + ), + ImplicitSolvent( + name="o-cresol", + smiles="CC1=CC=CC=C1O", + aliases=["o-cresol", "ortho-cresol", "2-methylphenol"], + orca="o-cresol", + g09="o-Cresol", + nwchem="o-cresol", + mopac="o-cresol", + qchem="o-cresol", + ), + ImplicitSolvent( + name="cyclohexanone", + smiles="O=C1CCCCC1", + aliases=["cyclohexanone"], + orca="cyclohexanone", + g09="CycloHexanone", + nwchem="cychexon", + mopac="cyclohexanone", + qchem="cyclohexanone", + ), + ImplicitSolvent( + name="isoquinoline", + smiles="C12=C(C=NC=C2)C=CC=C1", + aliases=["isoquinoline"], + g09="IsoQuinoline", + mopac="isoquinoline", + ), + ImplicitSolvent( + name="quinoline", + smiles="C12=CC=CC=C1N=CC=C2", + aliases=["quinoline"], + g09="Quinoline", + mopac="quinoline", + ), + ImplicitSolvent( + name="argon", + smiles="[Ar]", + aliases=["argon"], + g09="Argon", + mopac="argon", + ), + ImplicitSolvent( + name="krypton", + smiles="[Kr]", + aliases=["krypton"], + g09="Krypton", + mopac="krypton", + ), + ImplicitSolvent( + name="xenon", + smiles="[Xe]", + aliases=["xenon"], + g09="Xenon", + mopac="xenon", + ), +] + + +# Dielectric constants from Gaussian solvent list. Thanks to Joseph Silcock +# for PAINSTAKINGLY extracting these +_solvents_and_dielectrics = { + "acetic acid": 6.25, + "acetone": 20.49, + "acetonitrile": 35.69, + "benzene": 2.27, + "1-butanol": 17.33, + "2-butanone": 18.25, + "carbon tetrachloride": 2.23, + "chlorobenzene": 5.70, + "chloroform": 4.71, + "cyclohexane": 2.02, + "1,2-dichlorobenzene": 9.99, + "dichloromethane": 8.93, + "n,n-dimethylacetamide": 37.78, + "n,n-dimethylformamide": 37.22, + "1,4-dioxane": 2.21, + "ether": 4.24, + "ethyl acetate": 5.99, + "tce": 3.42, + "ethyl alcohol": 24.85, + "heptane": 1.91, + "hexane": 1.88, + "pentane": 1.84, + "1-propanol": 20.52, + "pyridine": 12.98, + "tetrahydrofuran": 7.43, + "toluene": 2.37, + "water": 78.36, + "cs2": 2.61, + "dmso": 46.82, + "methanol": 32.61, + "2-butanol": 15.94, + "acetophenone": 17.44, + "aniline": 6.89, + "anisole": 4.22, + "benzaldehyde": 18.22, + "benzonitrile": 25.59, + "benzyl chloride": 6.72, + "isobutyl bromide": 7.78, + "bromobenzene": 5.40, + "bromoethane": 9.01, + "bromoform": 4.25, + "bromooctane": 5.02, + "bromopentane": 6.27, + "butanal": 13.45, + "1,1,1-trichloroethane": 7.08, + "cyclopentane": 1.96, + "1,1,2-trichloroethane": 7.19, + "cyclopentanol": 16.99, + "1,2,4-trimethylbenzene": 2.37, + "cyclopentanone": 13.58, + "1,2-dibromoethane": 4.93, + "1,2-dichloroethane": 10.13, + "cis-decalin": 2.21, + "trans-decalin": 2.18, + "decalin": 2.20, + "1,2-ethanediol": 40.25, + "decane": 1.98, + "dibromomethane": 7.23, + "dibutylether": 3.05, + "z-1,2-dichloroethene": 9.20, + "e-1,2-dichloroethene": 2.14, + "1-bromopropane": 8.05, + "2-bromopropane": 9.36, + "1-chlorohexane": 5.95, + "1-ChloroPentane": 6.50, + "1-chloropropane": 8.35, + "diethylamine": 3.58, + "decanol": 7.53, + "diiodomethane": 5.32, + "1-fluorooctane": 3.89, + "heptanol": 11.32, + "cisdmchx": 2.06, + "diethyl sulfide": 5.73, + "diisopropyl ether": 3.38, + "hexanol": 12.51, + "hexene": 2.07, + "hexyne": 2.62, + "iodobutane": 6.17, + "1-iodohexadecane": 3.53, + "diphenylether": 3.73, + "1-iodopentane": 5.70, + "1-iodopropane": 6.96, + "dipropylamine": 2.91, + "dodecane": 2.01, + "1-nitropropane": 23.73, + "ethanethiol": 6.67, + "nonanol": 8.60, + "octanol": 9.86, + "pentanol": 15.13, + "pentene": 1.99, + "ethylbenzene": 2.43, + "tbp": 8.18, + "2,2,2-trifluoroethanol": 26.73, + "fluorobenzene": 5.42, + "2,2,4-trimethylpentane": 1.94, + "formamide": 108.94, + "2,4-dimethylpentane": 1.89, + "2,4-dimethylpyridine": 9.41, + "2,6-dimethylpyridine": 7.17, + "hexadecane": 2.04, + "dimethyl disulfide": 9.60, + "ethyl methanoate": 8.33, + "phentole": 4.18, + "formic acid": 51.1, + "hexanoic acid": 2.6, + "2-chlorobutane": 8.39, + "2-heptanone": 11.66, + "2-hexanone": 14.14, + "2-methoxyethanol": 17.20, + "isobutanol": 16.78, + "tertbutanol": 12.47, + "2-methylpentane": 1.89, + "2-methylpyridine": 9.95, + "2-nitropropane": 25.65, + "2-octanone": 9.47, + "2-pentanone": 15.20, + "iodobenzene": 4.55, + "iodoethane": 7.62, + "iodomethane": 6.87, + "isopropylbenzene": 2.37, + "p-cymene": 2.23, + "mesitylene": 2.27, + "methyl benzoate": 6.74, + "methyl butanoate": 5.56, + "methyl acetate": 6.86, + "methyl formate": 8.84, + "methyl propanoate": 6.08, + "n-methylaniline": 5.96, + "methylcyclohexane": 2.02, + "nmfmixtr": 181.56, + "nitrobenzene": 34.81, + "nitroethane": 28.29, + "nitromethane": 36.56, + "o-nitrotoluene": 25.67, + "n-nonane": 1.96, + "n-octane": 1.94, + "n-pentadecane": 2.03, + "pentanal": 10.00, + "pentanoic acid": 2.69, + "pentyl acetate": 4.73, + "pentylamine": 4.20, + "perfluorobenzene": 2.03, + "propanal": 18.50, + "propanoic acid": 3.44, + "cyanoethane": 29.32, + "propyl acetate": 5.52, + "propylamine": 4.99, + "tetrachloroethene": 2.27, + "sulfolane": 43.96, + "tetralin": 2.77, + "thiophene": 2.73, + "thiophenol": 4.27, + "triethylamine": 2.38, + "n-undecane": 1.99, + "xylene mix": 3.29, + "m-xylene": 2.35, + "o-xylene": 2.55, + "p-xylene": 2.27, + "2-propanol": 19.26, + "2-propen-1-ol": 19.01, + "e-2-pentene": 2.05, + "3-methylpyridine": 11.65, + "3-pentanone": 16.78, + "4-heptanone": 12.26, + "mibk": 12.88, + "4-methylpyridine": 11.96, + "5-nonanone": 10.6, + "benzyl alcohol": 12.46, + "butanoic acid": 2.99, + "butanenitrile": 24.29, + "butyl acetate": 4.99, + "butylamine": 4.62, + "n-butylbenzene": 2.36, + "s-butylbenzene": 2.34, + "t-butylbenzene": 2.34, + "o-chlorotoluene": 4.63, + "m-cresol": 12.44, + "o-cresol": 6.76, + "cyclohexanone": 15.62, + "isoquinoline": 11.00, + "quinoline": 9.16, + "argon": 1.43, + "krypton": 1.52, + "xenon": 1.70, +} diff --git a/autodE/source/autode/species/__init__.py b/autodE/source/autode/species/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..913aa51d57ddfe42453b25f58290119e255a3acc --- /dev/null +++ b/autodE/source/autode/species/__init__.py @@ -0,0 +1,19 @@ +from autode.species.molecule import Reactant +from autode.species.molecule import Product +from autode.species.molecule import Molecule +from autode.species.species import Species +from autode.species.complex import Complex +from autode.species.complex import NCIComplex +from autode.species.complex import ReactantComplex +from autode.species.complex import ProductComplex + +__all__ = [ + "Species", + "Reactant", + "Product", + "Molecule", + "Complex", + "ReactantComplex", + "NCIComplex", + "ProductComplex", +] diff --git a/autodE/source/autode/species/complex.py b/autodE/source/autode/species/complex.py new file mode 100644 index 0000000000000000000000000000000000000000..b44b6533ee22e79fe9a9eccc2a68324398045eab --- /dev/null +++ b/autodE/source/autode/species/complex.py @@ -0,0 +1,471 @@ +from copy import deepcopy +import numpy as np +from itertools import product as iterprod +from typing import Optional, Union, List, Sequence, TYPE_CHECKING + +from autode.atoms import Atom, Atoms +from scipy.spatial import distance_matrix +from autode.log import logger +from autode.geom import get_points_on_sphere +from autode.solvent.solvents import get_solvent +from autode.mol_graphs import union +from autode.species.species import Species +from autode.utils import requires_atoms, work_in +from autode.config import Config +from autode.methods import get_lmethod +from autode.conformers import Conformer +from autode.exceptions import MethodUnavailable + +if TYPE_CHECKING: + from autode.values import Angle + from autode.solvent import Solvent + + +def get_complex_conformer_atoms(molecules, rotations, points): + """ + Generate a conformer of a complex given a set of molecules, rotations for + each and points on which to shift + + ----------------------------------------------------------------------- + Arguments: + molecules (list(autode.species.Species)): + + rotations (list(np.ndarray)): List of len 4 np arrays containing the + [theta, x, y, z] defining the rotation + amount and axis + + points: (list(np.ndarray)): List of length 3 np arrays containing the + point to add the molecule with index i + + Returns: + (list(autode.atoms.Atom)) + """ + assert len(molecules) - 1 == len(rotations) == len(points) > 0 + + # First molecule is static so start with those atoms + atoms = deepcopy(molecules[0].atoms) + + # For each molecule add it to the current set of atoms with the centroid + # ~ COM located at the origin + for i, molecule in enumerate(molecules[1:]): + centroid = np.average(np.array([atom.coord for atom in atoms]), axis=0) + + # Shift to the origin and rotate randomly, by the same amount + theta, axis = np.random.uniform(-np.pi, np.pi), np.random.uniform( + -1, 1, size=3 + ) + for atom in atoms: + atom.translate(vec=-centroid) + atom.rotate(axis, theta) + + coords = np.array([atom.coord for atom in atoms]) + + mol_centroid = np.average(molecule.coordinates, axis=0) + shifted_mol_atoms = deepcopy(molecule.atoms) + + # Shift the molecule to the origin then rotate randomly + theta, axis = rotations[i][0], rotations[i][1:] + for atom in shifted_mol_atoms: + atom.translate(vec=-mol_centroid) + atom.rotate(axis, theta) + + # Shift until the current molecules don't overlap with the current + # atoms, i.e. aren't far enough apart + far_enough_apart = False + + # Shift the molecule by 0.1 Å in the direction of the point + # (which has length 1) until the + # minimum distance to the rest of the complex is 2.0 Å + while not far_enough_apart: + for atom in shifted_mol_atoms: + atom.coord += points[i] * 0.1 + + mol_coords = np.array([atom.coord for atom in shifted_mol_atoms]) + + if np.min(distance_matrix(coords, mol_coords)) > 2.0: + far_enough_apart = True + + atoms += shifted_mol_atoms + + return atoms + + +class Complex(Species): + def __init__( + self, + *args: Species, + name: str = "complex", + do_init_translation: bool = False, + copy: bool = True, + solvent_name: Optional[str] = None, + ): + """ + Molecular complex e.g. VdW complex of one or more Molecules + + ----------------------------------------------------------------------- + Arguments: + *args (autode.species.Species): + + Keyword Arguments: + name (str): + + do_init_translation (bool): Translate molecules initially such + that they donot overlap + + copy (bool): Should the molecules be copied into this complex? + + solvent_name (str | None): Name of the solvent, if None then select + the first solvent from the constituent + molecules + """ + super().__init__( + name=name, + atoms=sum( + (deepcopy(mol.atoms) if copy else mol.atoms for mol in args), + None, + ), # type: ignore + charge=sum(mol.charge for mol in args), + mult=sum(m.mult for m in args) - (len(args) - 1), + ) + + self._molecules = list(args) + + if do_init_translation: + self._init_translation() + + self.solvent = self._init_solvent(solvent_name) + self.graph = union(graphs=[mol.graph for mol in self._molecules]) + + def __repr__(self): + return self._repr(prefix="Complex") + + def __eq__(self, other): + """Equality of two complexes""" + return isinstance(other, self.__class__) and all( + a == b for (a, b) in zip(self._molecules, other._molecules) + ) + + @Species.atoms.setter + def atoms(self, value: Union[List[Atom], Atoms, None]): + if value is None: + self.graph = None + self._molecules.clear() + + elif self.n_atoms != len(value): + raise ValueError( + f"Cannot set atoms in {self.name} with a " + "different number of atoms. Molecular composition" + " must have changed." + ) + + logger.warning( + f"Modifying the atoms of {self.name} - assuming the " + f"same molecular composition" + ) + return super(Complex, type(self)).atoms.fset(self, value) + + @property + def n_molecules(self) -> int: + """Number of molecules in this molecular complex""" + return len(self._molecules) + + def atom_indexes(self, mol_index: int): + """ + List of atom indexes of a molecule withibn a Complex + + ----------------------------------------------------------------------- + Arguments: + mol_index (int): Index of the molecule + """ + if mol_index not in set(range(self.n_molecules)): + raise AssertionError( + f"Could not get idxs for molecule {mol_index}" + f". Not present in this complex" + ) + + first_index = sum([mol.n_atoms for mol in self._molecules[:mol_index]]) + last_index = sum( + [mol.n_atoms for mol in self._molecules[: mol_index + 1]] + ) + + return list(range(first_index, last_index)) + + def reorder_atoms(self, mapping: dict) -> None: + """ + Reorder the atoms in this complex using a dictionary keyed with current + atom indexes and values as their new positions + + ----------------------------------------------------------------------- + Arguments: + mapping (dict): + """ + logger.warning( + f"Reordering the atoms in a complex ({self.name}) will" + f" not preserve the molecular composition" + ) + + return super().reorder_atoms(mapping) + + def _generate_conformers(self): + """ + Generate rigid body conformers of a complex by (1) Fixing the first m + olecule, (2) initialising the second molecule's COM evenly on the points + of a sphere around the first with a random rotation and (3) iterating + until all molecules in the complex have been added + """ + n = self.n_molecules + + if n < 2: + # Single (or zero) molecule complex only has a single *rigid body* + # conformer + self.conformers = [Conformer(name=self.name, species=self)] + return None + + self.conformers = [] + m = 0 # Current conformer number + + points_on_sphere = get_points_on_sphere( + n_points=Config.num_complex_sphere_points + ) + + for _ in iterprod( + range(Config.num_complex_random_rotations), repeat=n - 1 + ): + # Generate the rotation thetas and axes + rotations = [ + np.random.uniform(-np.pi, np.pi, size=4) for _ in range(n - 1) + ] + + for points in iterprod(points_on_sphere, repeat=n - 1): + conf = Conformer( + name=f"{self.name}_conf{m}", + charge=self.charge, + mult=self.mult, + ) + conf.solvent = self.solvent + conf.atoms = get_complex_conformer_atoms( + self._molecules, rotations, points + ) + self.conformers.append(conf) + m += 1 + + if m == Config.max_num_complex_conformers: + logger.warning( + f"Generated the maximum number of complex " + f"conformers ({m})" + ) + return None + + logger.info(f"Generated {m} conformers") + return None + + @work_in("conformers") + def populate_conformers(self): + r""" + Generate and optimise with a low level method a set of conformers, the + number of which is:: + + Config.num_complex_sphere_points × Config.num_complex_random_rotations + ^ (n molecules in complex - 1) + + This will not be exact as get_points_on_sphere does not return quite + the desired number of points for small N. + """ + n_confs = ( + Config.num_complex_sphere_points + * Config.num_complex_random_rotations + * (self.n_molecules - 1) + ) + logger.info( + f"Generating and optimising {n_confs} conformers of " + f"{self.name} with a low-level method" + ) + + self._generate_conformers() + + try: + lmethod = get_lmethod() + for conformer in self.conformers: + conformer.optimise(method=lmethod) + conformer.print_xyz_file() + + except MethodUnavailable: + logger.error("Could not optimise complex conformers") + + return None + + def translate_mol(self, vec: Sequence[float], mol_index: int): + """ + Translate a molecule within a complex by a vector + + ----------------------------------------------------------------------- + Arguments: + vec (np.ndarray | list(float)): Length 3 vector + + mol_index (int): Index of the molecule to translate. e.g. 2 will + translate molecule 1 in the complex + they are indexed from 0 + """ + logger.info( + f"Translating molecule {mol_index} by {vec} in {self.name}" + ) + + if mol_index not in set(range(self.n_molecules)): + raise ValueError( + f"Could not translate molecule {mol_index} " + "not present in this complex" + ) + + for atom_idx in self.atom_indexes(mol_index): + self.atoms[atom_idx].translate(vec) + + return None + + def rotate_mol( + self, + axis: Union[np.ndarray, Sequence], + theta: Union["Angle", float], + mol_index: int, + origin: Union[np.ndarray, Sequence, None] = None, + ): + """ + Rotate a molecule within a complex an angle theta about an axis given + an origin + + ----------------------------------------------------------------------- + Arguments: + axis (np.ndarray | list): Length 3 vector + + theta (float | autode.values.Angle): + + origin (np.ndarray | list): Length 3 vector + + mol_index (int): Index of the molecule to translate. e.g. 2 will + translate molecule 1 in the complex + they are indexed from 0 + """ + logger.info( + f"Rotating molecule {mol_index} by {theta:.4f} radians " + f"in {self.name}" + ) + + if mol_index not in set(range(self.n_molecules)): + raise ValueError( + f"Could not rotate molecule {mol_index} " + "not present in this complex" + ) + + for atom_idx in self.atom_indexes(mol_index): + self.atoms[atom_idx].rotate(axis, theta, origin) + + return None + + @requires_atoms + def calc_repulsion(self, mol_index: int): + """Calculate the repulsion between a molecule and the rest of the + complex""" + + coords = self.coordinates + + mol_indexes = self.atom_indexes(mol_index) + mol_coords = [coords[i] for i in mol_indexes] + other_coords = [ + coords[i] for i in range(self.n_atoms) if i not in mol_indexes + ] + + # Repulsion is the sum over all pairs 1/r^4 + distance_mat = distance_matrix(mol_coords, other_coords) + repulsion = 0.5 * np.sum(np.power(distance_mat, -4)) + + return repulsion + + def _init_translation(self): + """Translate all molecules initially to avoid overlaps""" + + if self.n_molecules < 2: + return # No need to translate 0 or 1 molecule + + # Points on the unit sphere maximally displaced from one another + points = get_points_on_sphere(n_points=self.n_molecules) + + # Shift along the vector defined on the unit sphere by the molecule's + # radius + 4Å, which should generate a somewhat reasonable geometry + for i in range(self.n_molecules): + self.translate_mol( + vec=(self._molecules[i].radius + 4) * points[i], mol_index=i + ) + return None + + def _init_solvent( + self, solvent_name: Optional[str] + ) -> Optional["Solvent"]: + """Initial solvent""" + + if solvent_name is not None: + return get_solvent(solvent_name, kind="implicit") + + if self.n_molecules > 0: + solvent = self._molecules[0].solvent + if any(solvent != mol.solvent for mol in self._molecules): + raise AssertionError( + "Cannot form a complex with molecules in " + "different solvents" + ) + + return solvent + + return None + + +class ReactantComplex(Complex): + # NOTE: Methods must be identical to ProductComplex + + def to_product_complex(self): + """Return a product complex from this reactant complex""" + + prod_complex = self.copy() + prod_complex.__class__ = ProductComplex + + return prod_complex + + def __init__(self, *args, name="reac_complex", **kwargs): + """ + Reactant complex + + ----------------------------------------------------------------------- + Arguments: + *args (autode.species.Reactant): + + Keyword Arguments: + name (str): + """ + super().__init__(*args, name=name, **kwargs) + + +class ProductComplex(Complex): + # NOTE: Methods must be identical to ReactantComplex + + def to_reactant_complex(self): + """Return a reactant complex from this product complex""" + + reac_complex = self.copy() + reac_complex.__class__ = ReactantComplex + + return reac_complex + + def __init__(self, *args, name="prod_complex", **kwargs): + """ + Product complex + + ----------------------------------------------------------------------- + Arguments: + *args (autode.species.Product): + + Keyword Arguments: + name (str): + """ + super().__init__(*args, name=name, **kwargs) + + +class NCIComplex(Complex): + """Non covalent interaction complex""" diff --git a/autodE/source/autode/species/molecule.py b/autodE/source/autode/species/molecule.py new file mode 100644 index 0000000000000000000000000000000000000000..c7fc7fc666aa1577586d6550a1f9a7718bd2a862 --- /dev/null +++ b/autodE/source/autode/species/molecule.py @@ -0,0 +1,287 @@ +import re +import rdkit +from pathlib import Path +from typing import Optional, List, Any +from rdkit.Chem import AllChem + +from autode.log.methods import methods +from autode.input_output import xyz_file_to_atoms, attrs_from_xyz_title_line +from autode.conformers.conformer import Conformer +from autode.conformers.conf_gen import get_simanl_conformer +from autode.conformers.conformers import atoms_from_rdkit_mol +from autode.atoms import metals, Atom +from autode.config import Config +from autode.log import logger +from autode.mol_graphs import make_graph +from autode.smiles.smiles import init_organic_smiles +from autode.smiles.smiles import init_smiles +from autode.species.species import Species +from autode.utils import requires_atoms, ProcessPool + + +class Molecule(Species): + def __init__( + self, + arg: str = "molecule", + smiles: Optional[str] = None, + atoms: Optional[List["Atom"]] = None, + solvent_name: Optional[str] = None, + charge: Optional[int] = None, + mult: Optional[int] = None, + **kwargs, + ): + """ + A molecular species constructable from SMILES or a set of atoms, + has default charge and spin multiplicity. + + ----------------------------------------------------------------------- + Arguments: + arg: Name of the molecule or a .xyz filename + + smiles: Standard SMILES string. e.g. generated by Chemdraw + + atoms: List of atoms in the species + + solvent_name: Solvent that the molecule is immersed in + + charge: Charge on the molecule. If unspecified defaults to, if + present, that defined in a xyz file or 0 + + mult: Spin multiplicity on the molecule. If unspecified defaults to, if + present, that defined in a xyz file or 1 + + Keyword Arguments: + name: Name of the molecule. Overrides arg if arg is not a .xyz + filename + """ + logger.info(f"Generating a Molecule object for {arg}") + super().__init__( + name=arg if "name" not in kwargs else kwargs["name"], + atoms=atoms, + charge=charge if charge is not None else 0, + mult=mult if mult is not None else 1, + solvent_name=solvent_name, + ) + self.smiles = smiles + self.rdkit_mol_obj = None + self.rdkit_conf_gen_is_fine = True + + if _is_xyz_filename(arg): + self._init_xyz_file( + xyz_filename=arg, + charge=charge, + mult=mult, + solvent_name=solvent_name, + ) + + if smiles is not None: + assert not _is_xyz_filename(arg), "Can't be both SMILES and file" + self._init_smiles(smiles, charge=charge) + + # If the name is unassigned use a more interpretable chemical formula + if self.name == "molecule" and self.atoms is not None: + self.name = self.formula + + def __repr__(self): + return self._repr(prefix="Molecule") + + def __eq__(self, other): + """Equality of two molecules is only dependent on the identity""" + return super().__eq__(other) + + def _init_smiles(self, smiles: str, charge: Optional[int]): + """Initialise a molecule from a SMILES string using RDKit if it's + purely organic. + + ----------------------------------------------------------------------- + Arguments: + smiles (str): + """ + at_strings = re.findall(r"\[.*?]", smiles) + + if any(metal in string for metal in metals for string in at_strings): + init_smiles(self, smiles) + + else: + init_organic_smiles(self, smiles) + + if charge is not None and charge != self._charge: + raise ValueError( + "SMILES charge was not the same as the " + f"defined value. {self._charge} ≠ {charge}" + ) + + logger.info( + f"Initialisation with SMILES successful. " + f"Charge={self.charge}, Multiplicity={self.mult}, " + f"Num. Atoms={self.n_atoms}" + ) + return None + + def _init_xyz_file(self, xyz_filename: str, **override_attrs: Any): + """ + Initialise a molecule from a .xyz file + + ----------------------------------------------------------------------- + Arguments: + xyz_filename (str): + charge (int | None): + mult (int | None): + + Raises: + (ValueError) + """ + logger.info("Generating a species from .xyz file") + + self.atoms = xyz_file_to_atoms(xyz_filename) + title_line_attrs = attrs_from_xyz_title_line(xyz_filename) + logger.info(f"Found ({title_line_attrs}) in title line") + + for attr in ("charge", "mult", "solvent_name"): + if override_attrs[attr] is None and attr in title_line_attrs: + setattr(self, attr, title_line_attrs[attr]) + + if ( + sum(atom.atomic_number for atom in self.atoms) % 2 != 0 + and self.charge % 2 == 0 + and self.mult == 1 + ): + raise ValueError( + "Initialised a molecule from an xyz file with " + "an odd number of electrons but had an even " + "charge and 2S + 1 = 1. Impossible!" + ) + + # Override the default name with something more descriptive + if self.name == "molecule" or _is_xyz_filename(self.name): + self.name = Path(self.name).stem + + make_graph(self) + return None + + @requires_atoms + def _generate_conformers(self, n_confs: Optional[int] = None): + """ + Use a simulated annealing approach to generate conformers for this + molecule. + + ----------------------------------------------------------------------- + Keyword Arguments: + n_confs (int): Number of conformers requested if None default to + autode.Config.num_conformers + """ + + n_confs = n_confs if n_confs is not None else Config.num_conformers + self.conformers.clear() + + if self.smiles is not None and self.rdkit_conf_gen_is_fine: + logger.info(f"Using RDKit to gen conformers. {n_confs} requested") + + m_string = _rdkit_conformer_method_string() + logger.info(f"Using the {m_string} method") + method_class = getattr(AllChem, m_string) + method = method_class() + method.pruneRmsThresh = Config.rmsd_threshold + method.numThreads = Config.n_cores + try: + method.useSmallRingTorsion = True + except AttributeError: + logger.warning("Failed to turn on RDKit small ring torsions") + + logger.info( + "Running conformation generation with RDKit... running" + ) + conf_ids = list( + AllChem.EmbedMultipleConfs( + self.rdkit_mol_obj, numConfs=n_confs, params=method + ) + ) + logger.info(" ... done") + + for conf_id in conf_ids: + conf = Conformer( + species=self, name=f"{self.name}_conf{conf_id}" + ) + conf.atoms = atoms_from_rdkit_mol(self.rdkit_mol_obj, conf_id) + self.conformers.append(conf) + + methods.add( + f"{m_string} algorithm (10.1021/acs.jcim.5b00654) " + f"implemented in RDKit v. {rdkit.__version__}" + ) + + else: + logger.info("Using repulsion+relaxed (RR) to generate conformers") + with ProcessPool(max_workers=Config.n_cores) as pool: + results = [ + pool.submit(get_simanl_conformer, self, None, i) + for i in range(n_confs) + ] + self.conformers = [res.result() for res in results] # type: ignore + + self.conformers.prune_on_energy(e_tol=1e-10) + methods.add( + "RR algorithm (10.1002/anie.202011941) implemented in autodE" + ) + + self.conformers.prune_on_rmsd() + return None + + def populate_conformers(self, n_confs: Optional[int] = None) -> None: + """ + Populate self.conformers with a conformers generated using a default + method + + ----------------------------------------------------------------------- + Arguments: + n_confs (int): Number of conformers to try and generate + """ + return self._generate_conformers(n_confs=n_confs) + + def to_product(self) -> "Product": + """ + Generate a copy of this reactant as a product + + ----------------------------------------------------------------------- + Returns: + (autode.species.molecule.Product): Product + """ + product = self.copy() # type: ignore + product.__class__ = Product + + return product # type: ignore + + def to_reactant(self) -> "Reactant": + """ + Generate a copy of this product as a reactant + + ----------------------------------------------------------------------- + Returns: + (autode.species.molecule.Reactant): Reactant + """ + reactant = self.copy() # type: ignore + reactant.__class__ = Reactant + + return reactant # type: ignore + + +class Reactant(Molecule): + """Reactant molecule""" + + +class Product(Molecule): + """Product molecule""" + + +def _rdkit_conformer_method_string() -> str: + """Get the method for RDKit, depending on the version""" + if hasattr(AllChem, "srETKDGv3"): + return "srETKDGv3" + elif hasattr(AllChem, "ETKDGv3"): + return "ETKDGv3" + else: + return "ETKDGv2" + + +def _is_xyz_filename(value: str) -> bool: + return isinstance(value, str) and value.endswith(".xyz") diff --git a/autodE/source/autode/species/species.py b/autodE/source/autode/species/species.py new file mode 100644 index 0000000000000000000000000000000000000000..411009e97bce6967f0363dd3606da3faa48b6dea --- /dev/null +++ b/autodE/source/autode/species/species.py @@ -0,0 +1,1620 @@ +import numpy as np +import autode.values as val +from copy import deepcopy +from datetime import date +from typing import Optional, Union, List, Sequence, Any, TypeVar, TYPE_CHECKING + +from scipy.spatial import distance_matrix +from autode.log import logger +from autode import methods +from autode.atoms import Atom, Atoms, AtomCollection +from autode.exceptions import SolventUnavailable +from autode.geom import calc_rmsd, get_rot_mat_euler +from autode.constraints import Constraints +from autode.log.methods import methods as method_log +from autode.calculations.types import CalculationType +from autode.conformers.conformers import Conformers +from autode.solvent import get_solvent, Solvent, ExplicitSolvent +from autode.calculations import Calculation +from autode.config import Config +from autode.input_output import atoms_to_xyz_file +from autode.mol_graphs import ( + MolecularGraph, + make_graph, + reorder_nodes, + is_isomorphic, +) +from autode.hessians import Hessian, NumericalHessianCalculator +from autode.units import ha_per_ang_sq, ha_per_ang +from autode.thermochemistry.symmetry import symmetry_number +from autode.thermochemistry.igm import calculate_thermo_cont, LFMethod +from autode.utils import requires_atoms, work_in, requires_conformers +from autode.wrappers.keywords import ( + OptKeywords, + HessianKeywords, + GradientKeywords, + SinglePointKeywords, +) + +if TYPE_CHECKING: + from autode.solvent.solvents import Solvent + from autode.conformers import Conformer, Conformers + from autode.wrappers.methods import Method + from autode.wrappers.keywords import Keywords + from autode.values import Coordinates + +TypeSpecies = TypeVar("TypeSpecies", bound="Species") + + +class Species(AtomCollection): + def __init__( + self, + name: str, + atoms: Union[List[Atom], Atoms, None], + charge: Union[float, int], + mult: Union[float, int], + solvent_name: Optional[str] = None, + ): + """ + A molecular species. A collection of atoms with a charge and spin + multiplicity in a solvent (None is gas phase) + + ---------------------------------------------------------------------- + Arguments: + name (str): Name of the species + + atoms (list(autode.atoms.Atom) | None): List of atoms in the + species, or None + + charge (int): Charge on the species + + mult (int): Spin multiplicity of the species. 2S+1, where S is the + number of unpaired electrons + + Keyword Arguments: + solvent_name (str | None): Name of the solvent, or None for a + species in the gas phase + """ + super().__init__(atoms=atoms) + + self.name = name + + self._charge = int(charge) + self._mult = int(mult) + self._solvent = get_solvent(solvent_name, kind="implicit") + self._graph: Optional[MolecularGraph] = None + + #: All energies calculated at a geometry (autode.values.Energies) + self.energies = val.Energies() + self._grad: Optional[val.Gradient] = None + self._hess: Optional[Hessian] = None + + self._conformers = Conformers() + + self.constraints = Constraints() + + def __str__(self): + """Unique species identifier""" + + if self.atoms is None: + atoms_str = "" + + else: + # Only use the first 100 atoms + atoms_str = "".join([atom.label for atom in self.atoms[:100]]) + + solv_str = self.solvent.name if self.solvent is not None else "none" + + return f"{self.name}_{self.charge}_{self.mult}_{atoms_str}_{solv_str}" + + def _repr(self, prefix: str): + """Base representation of a Species/Molecule/Complex etc.""" + + string = ( + f"{prefix}(" + f"{self.name}, " + f"n_atoms={self.n_atoms}, " + f"charge={self.charge}, " + f"mult={self.mult})" + ) + + return string + + def __repr__(self): + """Brief representation of this species""" + return self._repr(prefix="Species") + + def __eq__(self, other) -> bool: + """ + Equality of this species to another. Only checks based on + the equality of the strings, which should be semi unique, without + checks for positional equality + """ + return str(self) == str(other) + + def copy(self: TypeSpecies) -> TypeSpecies: + """Copy this whole species""" + return deepcopy(self) + + def new_species( + self, name="species", with_constraints: bool = False + ) -> "Species": + """ + A new version of this species, identical properties without any + energies, gradients, hessian, conformers or constraints. + + ----------------------------------------------------------------------- + Arguments: + name (str): Name of the new species + + with_constraints (bool): Should the constraints from this species be copied + into the new one + + Returns: + (autode.species.Species): + """ + species = Species(name, self.atoms.copy(), self.charge, self.mult) + species.graph = None if self.graph is None else self.graph.copy() + species.solvent = None if self.solvent is None else self.solvent.copy() + + if with_constraints: + species.constraints = self.constraints.copy() + + return species + + @property + def charge(self) -> int: + """Total charge on this species""" + return self._charge + + @charge.setter + def charge(self, value: Any) -> None: + self._charge = int(value) + + @property + def mult(self) -> int: + """Total spin multiplicity on this species (2S + 1)""" + return self._mult + + @mult.setter + def mult(self, value: Any) -> None: + try: + assert int(value) > 0 + except (ValueError, AssertionError, TypeError): + raise ValueError( + f"Failed to set the spin multiplicity to {value}. " + f"Must be a non-zero positive integer" + ) + + self._mult = int(value) + + @property + def solvent(self) -> Optional["Solvent"]: + """ + Solvent which this species is immersed in + + ----------------------------------------------------------------------- + Returns: + (autode.solvent.Solvent | None): Solvent or None if the species is + in the gas phase + """ + return self._solvent + + @solvent.setter + def solvent(self, value: Union["Solvent", str, None]): + """ + Set the solvent for this species. For a species in the gas phase + set mol.solvent = None + + ----------------------------------------------------------------------- + Arguments; + value (autode.solvent.Solvent | str | None): + """ + if value is None: + self._solvent = None + + elif type(value) is str: + self._solvent = get_solvent(solvent_name=value, kind="implicit") + + elif isinstance(value, Solvent): + self._solvent = value + + else: + raise SolventUnavailable( + "Expecting either a string or Solvent, " f"had: {value}" + ) + + @AtomCollection.atoms.setter # type: ignore[attr-defined] + def atoms(self, value: Union[List[Atom], Atoms, None]): + """ + Set the atoms for this species, and reset the energies + + ----------------------------------------------------------------------- + Arguments: + value (list(autode.atoms.Atom) | None): + """ + + if value is None: + self._atoms = None + return + + # If the geometry is identical up to rotations/translations then + # energies do not need to be changed + if self.n_atoms == len(value) and all( + a.label == v.label for a, v in zip(self.atoms, value) + ): + self.coordinates = np.array([v.coord for v in value]) + + else: + self._atoms = Atoms(value) + self._clear_energies_gradient_hessian() + + return + + @AtomCollection.coordinates.setter # type: ignore[attr-defined] + def coordinates(self, value: Union["Coordinates", np.ndarray, list]): + """ + Set the coordinates of this species. If the geometry has changed then + the energies, gradient and Hessian will be set to None. + + ----------------------------------------------------------------------- + Arguments: + value: numpy array or nested list of coordinate values + (str or float). + """ + assert self._atoms is not None, "Can't set coordinates without atoms" + + rmsd = calc_rmsd( + coords1=np.asarray(value).reshape((-1, 3)), # N x 3 + coords2=self.coordinates, + ) + if rmsd > 1e-8: + self._clear_energies_gradient_hessian() + + self._atoms.coordinates = val.Coordinates(value) + return + + def _clear_energies_gradient_hessian(self) -> None: + logger.info(f"Geometry changed- resetting energies of {self.name}") + self.energies.clear() + self.gradient = None + self.hessian = None + return None + + @property + def graph(self) -> Optional[MolecularGraph]: + """ + Molecular graph with atoms(V) and bonds(E) + + Note: Graphs are lazily evaluated, i.e. if one has not been generated + for this species before and it does have atoms then a graph will be + generated. Subsequent accesses of this property will use the cached + internal/private self._graph attribute + """ + if self.atoms is None: + logger.warning("Had no atoms, so no molecular graph") + return None + + if self._graph is None: + make_graph(self) + + return self._graph + + @graph.setter + def graph(self, value: Optional[MolecularGraph]): + """Setter for the molecular graph""" + self._graph = value + + @property + def formula(self) -> str: + """ + Molecular formula of this species. Example: + + .. code-block:: Python + + >>> import autode as ade + >>> blank_mol = ade.Molecule() + >>> blank_mol.formula + '' + >>> h2 = ade.Molecule(smiles='[H][H]') + >>> h2.formula + 'H2' + + ----------------------------------------------------------------------- + Returns: + (str): Formula + """ + + if self.atoms is None: + return "" + + symbols = [atom.label for atom in self.atoms] + + formula_str = "" + for symbol in sorted(set(symbols)): + num = symbols.count(symbol) + formula_str += f'{symbol}{num if num > 1 else ""}' + + return formula_str + + @property + def hessian(self) -> Optional[Hessian]: + """ + Hessian (d^2E/dx^2) at this geometry (autode.values.Hessian | None) + shape = (3*n_atoms, 3*n_atoms) + """ + return self._hess + + @hessian.setter + def hessian(self, value: Union[Hessian, np.ndarray, None]): + """Set the Hessian matrix as a Hessian value""" + logger.info("Setting hessian") + + if value is None: + self._hess = None + return + + required_shape = (3 * self.n_atoms, 3 * self.n_atoms) + + if hasattr(value, "shape") and value.shape != required_shape: + raise ValueError( + "Could not set the Hessian. Incorrect shape: " + f"{value.shape} != {required_shape}" + ) + + elif isinstance(value, Hessian): + self._hess = value + + if self._hess.atoms is None: + self._hess.atoms = self.atoms + + elif isinstance(value, np.ndarray): + logger.warning( + "Setting the Hessian from a numpy array - assuming " + "units of Ha Å^-2" + ) + self._hess = Hessian(value, atoms=self.atoms, units=ha_per_ang_sq) + + else: + raise ValueError( + f"Could not set Hessian with {value}, Must be " + f"a numpy array or a Hessian." + ) + + @property + def gradient(self) -> Optional[val.Gradient]: + """ + Gradient (dE/dx) at this geometry. + + ----------------------------------------------------------------------- + Returns: + (autode.values.Gradients | None): Gradient with shape = (n_atoms, 3) + """ + return self._grad + + @gradient.setter + def gradient(self, value: Union[val.Gradient, np.ndarray, None]): + """Set the gradient matrix""" + + if value is None: + self._grad = None + return + + if hasattr(value, "shape") and value.shape != (self.n_atoms, 3): + try: + value = value.reshape((self.n_atoms, 3)) + except (ValueError, AttributeError): + raise ValueError( + "Could not set the gradient. Incorrect shape: " + f"{value.shape}. Must be either {(self.n_atoms, 3)}, " + f"or {(self.n_atoms * 3,)}" + ) + + if isinstance(value, val.Gradient): + self._grad = value + + elif isinstance(value, np.ndarray): + logger.warning( + "Setting the gradients from a numpy array - " + "assuming Ha / Å units" + ) + self._grad = val.Gradient(value, units=ha_per_ang) + + else: + raise ValueError( + f"Could not set the gradient with {value}, Must " + f"be a numpy array or a Gradient." + ) + + @property + def frequencies(self) -> Optional[List[val.Frequency]]: + """ + Frequencies from Hessian diagonalisation, in cm-1 by default and + are projected from rotation and translation + + ----------------------------------------------------------------------- + Returns: + (list(autode.values.Frequency) | None): + """ + if self.hessian is None: + logger.warning("No Hessian has been calculated - no frequencies") + return None + + return self.hessian.frequencies_proj + + @property + def vib_frequencies(self) -> Optional[List[val.Frequency]]: + """ + Vibrational frequencies, which are all but the lowest 6 for a + non-linear molecule and all but the lowest 5 for a linear one + + ----------------------------------------------------------------------- + Returns: + (list(autode.values.Frequency) | None): Vibrational frequencies + """ + n = 6 if not self.is_linear() else 5 + + return self.frequencies[n:] if self.frequencies is not None else None + + @property + def imaginary_frequencies(self) -> Optional[List[val.Frequency]]: + """ + Imaginary frequencies of a molecule + + ----------------------------------------------------------------------- + Returns: + (list(autode.values.Frequency) | None): Imaginary frequencies, or + None if there are none + """ + if self.frequencies is None: + logger.warning("Had no frequencies - could not find any imaginary") + return None + + imag_freqs = [freq for freq in self.frequencies if freq.is_imaginary] + + if len(imag_freqs) == 0: + logger.warning("No imaginary frequencies") + return None + + return imag_freqs + + def normal_mode(self, mode_number: int) -> Optional[val.Coordinates]: + """ + Vibrational normal mode indexed from 0, the first 6 are translation + and rotation and have zero displacements. The first vibrational mode + has mode_number = 6. + + ----------------------------------------------------------------------- + Arguments: + mode_number (int): + + Returns: + (autode.values.Coordinates): + """ + if self.hessian is None: + logger.warning("Could not calculate a normal mode displacement") + return None + + return self.hessian.normal_modes_proj[mode_number] + + @property + @requires_atoms + def bond_matrix(self) -> np.ndarray: + """ + Numpy boolean array containing which atoms are bonded, also known as + an adjacency matrix. + + ----------------------------------------------------------------------- + Returns: + (np.ndarray): Adjacency matrix. shape = (n_atoms, n_atoms) + """ + assert self.graph is not None, "Must have a molecular graph" + + matrix = np.zeros(shape=(self.n_atoms, self.n_atoms), dtype=bool) + + for bond in self.graph.edges: + matrix[tuple(bond)] = matrix[tuple(reversed(bond))] = True + + return matrix + + @property + def partial_charges(self) -> List[float]: + """Partial charges on all the atoms present in this species""" + return [atom.partial_charge for atom in self.atoms] + + @partial_charges.setter + def partial_charges(self, value: List[float]): + """Partial charges on all the atoms present in this species""" + + try: + _ = list(value) + assert len(value) == self.n_atoms + except (TypeError, ValueError, AssertionError): + raise ValueError( + f"Failed to assign partial charges from {value} " + f"must be a list with length n_atoms" + ) + + for atom, charge in zip(self.atoms, value): + atom.partial_charge = charge + + @property + def radius(self) -> val.Distance: + """ + Calculate an approximate radius of this species. Does not consider any + VdW radii of the outer most atoms i.e. purely determined on nuclear + positions + + ----------------------------------------------------------------------- + Returns: + (autode.values.Distance): Radius + """ + if self.n_atoms == 0: + return val.Distance(0.0) + + coords = self.coordinates + return val.Distance(np.max(distance_matrix(coords, coords)) / 2.0) + + @property + def sn(self) -> int: + """ + Calculate the symmetry number (σ_R) of the atoms. Only implemented for + 'small' molecules <50 atoms + + References: + [1] Theor Chem Account (2007) 118:813 + [2] . Phys. Chem. B (2010) 114:16304 + + ----------------------------------------------------------------------- + Returns: + (int): σ_R + """ + if self.n_atoms == 0: + return 1 + + if self.n_atoms > 50: + logger.warning( + "Symmetry number calculations are not implemented " + "for large molecules. Assuming C1 -> σ_R=1" + ) + return 1 + + return symmetry_number(self) + + @property + def is_explicitly_solvated(self) -> bool: + return self.solvent is not None and self.solvent.is_explicit + + @property + def is_implicitly_solvated(self) -> bool: + return self.solvent is not None and self.solvent.is_implicit + + @property + def atomic_symbols(self) -> List[str]: + """Atomic symbols of all atoms in this species""" + return [atom.label for atom in self.atoms] if self.atoms else [] + + @property + def sorted_atomic_symbols(self) -> List[str]: + """Atomic symbols of all atoms sorted alphabetically""" + return list(sorted(self.atomic_symbols)) + + @property + def atomic_masses(self) -> List[float]: + """Atom masses of all the atoms in this species""" + return [atom.mass for atom in self.atoms] if self.atoms else [] + + @property + def energy(self) -> Optional[val.PotentialEnergy]: + """ + Last computed potential energy. Setting with a float assumes electornic + Hartree units. Example: + + .. code-block:: Python + + >>> import autode as ade + >>> species = ade.Species(name='H', atoms=[ade.Atom('H')], charge=0, mult=1) + >>> species.energy is None + True + >>> species.energy = -0.5 + >>> species.energy + Energy(-0.5 Ha) + >>> species.single_point(method=ade.methods.ORCA()) + >>> species.energy + Energy(-0.50104 Ha) + + Energies are instances of autode.values.Energy so can be converted + to different units simply: + + .. code-block:: Python + + >>> species.energy.to('kcal mol-1') + Energy(-314.40567 kcal mol-1) + >>> species.energy.to('eV') + Energy(-13.63394 eV) + + All previsouly calculated energies of a species are availble with the + energies attribute: + + .. code-block:: Python + + >>> species.energies + [Energy(-0.5 Ha), Energy(-0.50104 Ha)] + + ----------------------------------------------------------------------- + Returns: + (autode.values.PotentialEnergy): Energy + """ + return self.energies.last(val.PotentialEnergy) + + @energy.setter + def energy(self, value: Union[val.Energy, str, float, None]): + """ + Add an energy to the list of energies at this geometry + + ----------------------------------------------------------------------- + Arguments: + value (float | autode.values.Energy | None): + """ + + if value is None: + # No change required + pass + + elif isinstance(value, val.PotentialEnergy): + self.energies.append(value) + + else: + # Attempt to cast the value to Potential energy + self.energies.append(val.PotentialEnergy(float(value))) + + @property + def h_cont(self) -> Optional[val.EnthalpyCont]: + """ + Return the enthalpic contribution to the energy + + ----------------------------------------------------------------------- + Returns: + (autode.values.Energy | None): H - E_elec + """ + return self.energies.last(val.EnthalpyCont) + + @property + def g_cont(self) -> Optional[val.FreeEnergyCont]: + """ + Return the Gibbs (free) contribution to the energy + + ----------------------------------------------------------------------- + Returns: + (autode.values.Energy | None): G - E_elec + """ + return self.energies.last(val.FreeEnergyCont) + + @property + def free_energy(self) -> Optional[val.FreeEnergy]: + """ + Free energy (G or A) of this species, calculated using the last energy + and free energy contribution + + ----------------------------------------------------------------------- + Returns: + (autode.values.FreeEnergy | None): 'Gibbs' free energy + """ + if self.energy is None or self.g_cont is None: + logger.warning("Could not calculate G - an energy was None") + return None + + return val.FreeEnergy(self.energy + self.g_cont) + + @property + def enthalpy(self) -> Optional[val.Enthalpy]: + """ + Enthalpy (H) of this species, calculated using the last energy and + enthalpy contribution. Example: + + .. code-block:: Python + + >>> import autode as ade + >>> h2 = ade.Molecule(smiles='[H][H]') + >>> orca = ade.methods.ORCA() + >>> + >>> h2.optimise(method=orca) + >>> h2.calc_h_cont(method=orca) + >>> h2.enthalpy + Enthalpy(-1.15069 Ha) + + The enthalpy contribution is seperated, so performing a single point + provides a new enthalpy using the electronic energy at the single-point + level of theory: + + .. code-block:: Python + + >>> h2.single_point(method=orca) + >>> h2.enthalpy + Enthalpy(-1.15497 Ha) + + ----------------------------------------------------------------------- + Returns: + (autode.values.Enthalpy | None): Enthalpy + """ + if self.energy is None or self.h_cont is None: + logger.warning("Could not calculate H - an energy was None") + return None + + return val.Enthalpy(self.energy + self.h_cont) + + @property + def zpe(self) -> Optional[val.Energy]: + """ + Zero point vibrational energy of this species. Any imaginary + vibrational frequencies present are converted to their real analogues. + + ----------------------------------------------------------------------- + Returns: + (autode.values.Energy | None): ZPE if frequencies are defined + """ + if self.n_atoms < 2: + # A single atom (or empty set) does not have any vibrational energy + return val.Energy(0.0) + + if self.vib_frequencies is None: + logger.warning( + "Vibrational frequencies not available, cannot " + "determine zero point energy" + ) + return None + + h = 6.62607004e-34 # Planks constant / J s + zpe = 0.5 * h * sum(nu.real.to("hz") for nu in self.vib_frequencies) + + return val.Energy(float(zpe), units="J").to("Ha") + + @property + def has_reasonable_coordinates(self) -> bool: + """ + Does this species have a 'reasonable' set of coordinates? I.e. No + atom-atom distances that are particularly short or long. Also checks + that all the atoms don't lie in a single plane, which is possible for + a failed 3D embedding of a structure. + + ----------------------------------------------------------------------- + Returns: + (bool): + """ + if self.n_atoms < 2: + return True + + dist_matrix = distance_matrix(self.coordinates, self.coordinates) + dist_matrix[np.diag_indices(self.n_atoms)] = 1.0 + + if np.min(dist_matrix) < 0.7 or np.max(dist_matrix) > 1e6: + logger.warning( + f"Species({self.name}) did not have a set of " + f"reasonable coordinates. Small or large distances" + ) + return False + + assert self.graph is not None, "Need graph to check" + if self.atoms.are_planar() and not self.graph.expected_planar_geometry: + logger.warning( + "Atoms lie in a plane but the molecular graph ⇒ " + f"a non-planar structure. Species({self.name}) did " + f"not have a reasonable set of coordinates" + ) + return False + + return True + + @property + def has_valid_spin_state(self) -> bool: + """ + Does this species have a valid spin state given the atomic composition and + charge state? + + .. code-block:: Python + + >>> import autode as ade + >>> h = ade.Molecule(atoms=[ade.Atom('H')], charge=0, mult=1) + >>> h.has_valid_spin_state + False + >>> hydride = ade.Molecule(atoms=[ade.Atom('H')], charge=-1, mult=1) + >>> hydride.has_valid_spin_state + True + """ + num_electrons = ( + sum(atom.atomic_number for atom in self.atoms) - self.charge + ) + num_unpaired_electrons = self.mult - 1 + return ( + num_unpaired_electrons <= num_electrons + and num_electrons % 2 == num_unpaired_electrons % 2 + ) + + @property + def n_conformers(self) -> int: + """ + Number of conformers of this species + + ----------------------------------------------------------------------- + Returns: + (int): + """ + return 0 if self.conformers is None else len(self.conformers) + + @property + def conformers(self) -> "Conformers": + """Conformers of this species""" + return self._conformers + + @conformers.setter + def conformers( + self, + value: Union[List["Conformer"], "Conformers", None], + ) -> None: + """ + Set conformers of this species + + ----------------------------------------------------------------------- + Arguments: + value (list(autode.conformers.Conformer) | None): + """ + if value is None: + self._conformers.clear() + return + + self._conformers = Conformers([conf for conf in value]) + + def _generate_conformers(self, *args, **kwargs): + raise NotImplementedError( + "Could not generate conformers. " + "generate_conformers() not implemented" + ) + + def _default_hessian_calculation( + self, method=None, keywords=None, n_cores=None + ): + """Construct a default Hessian calculation""" + + method = methods.method_or_default_hmethod(method) + keywords = keywords if keywords is not None else method.keywords.hess + + calc = Calculation( + name=f"{self.name}_hess", + molecule=self, + method=method, + keywords=HessianKeywords(keywords), + n_cores=Config.n_cores if n_cores is None else n_cores, + ) + return calc + + def _default_opt_calculation( + self, method=None, keywords=None, n_cores=None + ): + """Construct a default optimisation calculation""" + + method = methods.method_or_default_hmethod(method) + keywords = keywords if keywords is not None else method.keywords.opt + logger.info(f"Using keywords: {keywords} to optimise with {method}") + + calc = Calculation( + name=f"{self.name}_opt", + molecule=self, + method=method, + keywords=OptKeywords(keywords), + n_cores=Config.n_cores if n_cores is None else n_cores, + ) + + return calc + + def _run_hess_calculation(self, **kwargs): + """Run a Hessian calculation on this species + + ---------------------------------------------------------------------- + Keyword Arguments: + calc: Calculation, if undefined then use a default calculation + + method: Method to use for the calculation, if it's undefined. + Defaults to methods.get_hmethod() + + keywords: Keywords to use in a calculation, if it's undefined. + Defaults to method.keywords.hess + """ + + if self.n_atoms < 2: + logger.warning( + f"Not running a Hessian calculation on only " + f"{self.n_atoms} atom(s). Cannot have frequencies" + ) + return None + + calc = kwargs.pop("calc", None) + + if calc is None: + calc = self._default_hessian_calculation(**kwargs) + + calc.run() + return None + + @requires_conformers + def _set_lowest_energy_conformer(self): + """Set the species energy and atoms as those of the lowest energy + conformer""" + conformer = self.conformers.lowest_energy + + if conformer is None: + raise RuntimeError( + "Failed to set the lowest energy conformer as " + "no suitable conformers were present" + ) + + self.atoms = conformer.atoms.copy() + self.energy = conformer.energy + return None + + def populate_conformers(self, *args, **kwargs): + """Populate self.conformers""" + return self._generate_conformers(*args, **kwargs) + + @requires_atoms + def reorder_atoms(self, mapping: dict) -> None: + """ + Reorder the atoms in this species (in place) using a mapping. For + example, to reorder the atoms in a HF molecule: + + .. code-block:: Python + + >>> import autode as ade + >>> hf = ade.Species(name='HF', charge=0, mult=1, + ... atoms=[ade.Atom('H'), ade.Atom('F', x=1)]) + >>> hf.atoms + Atoms([Atom(H, 0.0 0.0 0.0), Atom(F, 1.0, 0.0, 0.0)]) + >>> hf.reorder_atoms(mapping={0: 1, 1: 0}) + + ----------------------------------------------------------------------- + Arguments: + mapping (dict): Dictionary keyed with current atom indexes with + the values as the required indexing + + Raises: + (ValueError): If the mapping is invalid + """ + if not ( + set(mapping.keys()) + == set(mapping.values()) + == set(list(range(self.n_atoms))) + ): + raise ValueError("Invalid mapping. Must be 1-1 for all atoms") + + self._atoms = Atoms( + [self.atoms[i] for i in sorted(mapping, key=lambda k: mapping[k])] + ) + + if self.graph is None: + return # No need to re-order a graph that is not set + + self.graph = reorder_nodes( + graph=self.graph, mapping={u: v for v, u in mapping.items()} + ) + return + + @requires_atoms + def is_linear( + self, + tol: Optional[float] = None, + angle_tol: val.Angle = val.Angle(1.0, "degrees"), + ) -> bool: + """ + Determine if a species is linear i.e all atoms are colinear + + ----------------------------------------------------------------------- + Keyword Arguments: + tol (float | None): Tolerance on |cos(θ)| - 1 where θ is the angle + between the vector from atom 0 to 1 and from + 0 to n (n > 1). Present for compatibility and + overrides angle_tol if not None + + angle_tol (autode.values.Angle): Tolerance on the angle considered + to be linear + """ + if tol is not None: + angle_tol = val.Angle(np.arccos(1.0 - tol), units="rad") + + return self.atoms.are_linear(angle_tol=angle_tol) + + @requires_atoms + def is_planar( + self, tol: Union[float, val.Distance] = val.Distance(1e-4) + ) -> bool: + """ + Determine if a species is planar i.e all atoms are coplanar + + ----------------------------------------------------------------------- + Keyword Arguments: + tol (float | None): Tolerance on the dot product between normal + vectors. + """ + return self.atoms.are_planar(distance_tol=tol) + + @requires_atoms + def translate(self, vec: Sequence[float]) -> None: + """ + Translate the molecule by vector + + ----------------------------------------------------------------------- + Arguments: + vec (np.ndarray | list(float)): Vector to translate by shape = (3,) + """ + for atom in self.atoms: + atom.translate(vec) + + return None + + @requires_atoms + def rotate( + self, + axis: Union[np.ndarray, Sequence], + theta: Union[val.Angle, float], + origin: Union[np.ndarray, Sequence, None] = None, + ) -> None: + """ + Rotate the molecule by around an axis + + ----------------------------------------------------------------------- + Arguments: + axis (np.ndarray | list(float)): Axis to rotate around. len(axis)=3 + + theta (Angle | float): Angle to rotate anticlockwise by if float + then assume radian units + + origin (np.ndarray | list(float) | None): Origin of the rotation + """ + + # NOTE: Requires copy as the origin may be one of the coordinates + origin = np.zeros(3) if origin is None else np.array(origin, copy=True) + + coords = self.coordinates + coords -= origin + coords = np.dot(coords, get_rot_mat_euler(axis=axis, theta=theta).T) + coords += origin + + # Set the new coordinates of each atom + for atom, new_coord in zip(self.atoms, coords): + atom.coord = new_coord + + return None + + @requires_atoms + def centre(self) -> None: + """Translate this molecule so the centroid (~COM) is at the origin""" + self.translate(vec=-np.average(self.coordinates, axis=0)) + return None + + @requires_atoms + def reset_graph(self) -> None: + """ + Reset the molecular graph of this species by its connectivity + """ + return make_graph(self) + + def has_same_connectivity_as(self, other: "Species") -> bool: + """ + Determine if this species have the same connectivity as another + + ----------------------------------------------------------------------- + Arguments: + other: A species which to check connectivity against + + Returns: + (bool): Does another species have the same connectivity? + """ + + if not (hasattr(other, "n_atoms") and hasattr(other, "graph")): + raise ValueError( + f"Could not check if {other} had the same " + f"connectivity as {self}, it had no n_atoms or " + "graph attribute" + ) + + if self.n_atoms != other.n_atoms: + return False # Must have an identical number of atoms + + if self.n_atoms <= 1: + return True # 1 or 0 atom molecules have the same connectivity + + if self.graph is None or other.graph is None: + raise ValueError( + "Cannot check connectivity, a graph was undefined" + ) + + return is_isomorphic(self.graph, other.graph) + + @requires_atoms + def print_xyz_file( + self, + title_line: Optional[str] = None, + filename: Optional[str] = None, + additional_title_line: Optional[str] = None, + with_solvent: bool = True, + append: bool = False, + ) -> None: + """ + Print a standard xyz file from this molecule's atoms + + ----------------------------------------------------------------------- + Keyword Arguments: + title_line: String to add as the second line of the .xyz file + + filename: Filename ending with .xyz. If None then will use the + name of this molecule + + additional_title_line: Additional elements to add to the title line + + with_solvent: If the solvent is explicit then include the solvent + atoms in the .xyz file + + append: Should the structure be appended to the existing file + """ + + if filename is None: + filename = f"{self.name}.xyz" + + # Default generated title line + if title_line is None: + title_line = ( + f"Generated by autodE on: {date.today()}. " + f"charge = {self.charge} " + f"mult = {self.mult} " + ) + if self.solvent is not None and self.solvent.is_implicit: + title_line += f"solvent_name = {self.solvent.name} " + if self.energy is not None: + title_line += f"E = {self.energy:.6f} Ha" + + if additional_title_line is not None: + title_line += additional_title_line + + atoms = self.atoms + # Add the explicit solvent molecules if present and requested + if ( + self.solvent is not None + and self.solvent.is_explicit + and with_solvent + ): + atoms += self.solvent.atoms + + atoms_to_xyz_file( + atoms=atoms, + filename=filename, + title_line=title_line, + append=append, + ) + return None + + @requires_atoms + def optimise( + self, + method: Optional["Method"] = None, + reset_graph: bool = False, + calc: Optional[Calculation] = None, + keywords: Union[Sequence[str], str, None] = None, + n_cores: Optional[int] = None, + ) -> None: + """ + Optimise the geometry using a method + + ----------------------------------------------------------------------- + Arguments: + method (autode.wrappers.base.ElectronicStructureMethod): + + reset_graph (bool): Reset the molecular graph + + calc (autode.calculation.Calculation): Different e.g. constrained + optimisation calculation + + keywords (list(str) | None): Calculation keywords to use, if None + then use the default for the method. + Does not include solvent-specific ones + + n_cores (int | None): Number of cores to use for the calculation, + if None then will default to + autode.Config.n_cores + Raises: + (autode.exceptions.CalculationException): + """ + logger.info(f"Running optimisation of {self.name}") + + if calc is None and method is None: + raise ValueError( + "Optimisation cannot be performed without " + "a specified method or calculation." + ) + + if calc is None: + calc = self._default_opt_calculation(method, keywords, n_cores) + + calc.run() + + method_name = "" if method is None else method.name + self.print_xyz_file( + filename=f"{self.name}_optimised_{method_name}.xyz" + ) + + if reset_graph: + self.reset_graph() + + return None + + @requires_atoms + def calc_thermo( + self, + method: Optional["Method"] = None, + calc: Optional[Calculation] = None, + temp: Union[val.Temperature, float] = val.Temperature(298.15), + keywords: Union[Sequence[str], str, None] = None, + **kwargs, + ) -> None: + """ + Calculate the free energy and enthalpy contributions using the + ideal gas approximation + + ----------------------------------------------------------------------- + Arguments: + method (autode.wrappers.base.ElectronicStructureMethod): + + calc (autode.calculation.Calculation): + + keywords (autode.wrappers.keywords.Keywords): + + temp (float | autode.values.Temperature): Temperature in K + + Keyword Arguments: + + lfm_method (LFMethod | str): Method to treat low frequency + modes. {'igm', 'truhlar', 'grimme'}. + Defaults to Config.lfm_method + + ss (str): Standard state to use. Defaults to Config.standard_state + + Raises: + (autode.exceptions.CalculationException | ValueError): + + See Also: + :meth:`autode.thermochemistry.igm.calculate_thermo_cont` for + additional kwargs + """ + logger.info( + f"Calculating thermochemical contributions for {self.name}" + ) + + if isinstance(temp, float): + logger.warning( + "Temperature defined as a float. Assuming units of K" + ) + temp = val.Temperature(temp) + + if "lfm_method" in kwargs: + try: + kwargs["lfm_method"] = LFMethod[kwargs["lfm_method"].lower()] + except KeyError: + raise ValueError( + f'{kwargs["lfm_method"]} is not valid. Must ' + f"be one of: {[m for m in LFMethod]}" + ) + + if calc is not None and calc.output.exists: + logger.info( + "Setting the atoms, energy and Hessian from an " + "existing calculation" + ) + if calc.molecule.hessian is None: + raise ValueError( + f"Failed to set the Hessian from {calc.name}." + f" Maybe run() hasn't been called?" + ) + + self.atoms = calc.molecule.atoms.copy() + self.energy = calc.molecule.energy + self.hessian = calc.molecule.hessian + + elif self.hessian is None or ( + calc is not None and not calc.output.exists + ): + logger.info( + "Calculation did not exist or Hessian was None - " + "calculating the Hessian" + ) + self._run_hess_calculation( + method=method, calc=calc, keywords=keywords + ) + + calculate_thermo_cont(self, temp=temp, **kwargs) + return None + + @requires_atoms + def calc_g_cont(self, *args, **kwargs) -> None: + """Calculate the Gibbs free (G) contribution for this species using + Species.calc_thermo()""" + return self.calc_thermo(*args, **kwargs) + + @requires_atoms + def calc_h_cont(self, *args, **kwargs) -> None: + """Calculate the enthalpic (H) contribution for this species using + Species.calc_thermo()""" + return self.calc_thermo(*args, **kwargs) + + @requires_atoms + def single_point( + self, + method: "Method", + keywords: Union["Keywords", Sequence[str], str, None] = None, + n_cores: Optional[int] = None, + ) -> None: + """ + Calculate the single point energy of the species using a method + + ----------------------------------------------------------------------- + Arguments: + method (autode.wrappers.base.ElectronicStructureMethod): + + keywords (list(str) | None): Calculation keywords to use, if None + then use the default for the method + + n_cores (int | None): Number of cores to use for the calculation, + if None then use autode.Config.n_cores + + Raises: + (autode.exceptions.CalculationException): + """ + logger.info(f"Running single point energy evaluation of {self.name}") + + if keywords is None: + keywords = method.keywords.sp + logger.info(f"Using default single point keywords: {keywords}") + + else: + keywords = SinglePointKeywords(keywords) + + assert keywords is not None, "Must have keywords" + sp = Calculation( + name=f"{self.name}_sp", + molecule=self, + method=method, + keywords=keywords, + n_cores=Config.n_cores if n_cores is None else n_cores, + ) + sp.run() + return None + + @work_in("conformers") + def find_lowest_energy_conformer( + self, + lmethod: Optional["Method"] = None, + hmethod: Optional["Method"] = None, + allow_connectivity_changes: bool = False, + ) -> None: + """ + Find the lowest energy conformer of this species. Populates + species.conformers and sets species.atoms and species.energy. By + default will only optimise at a low-level method + + ----------------------------------------------------------------------- + Keyword Arguments: + lmethod (autode.wrappers.ElectronicStructureMethod): Low-level + method to use. + + hmethod (autode.wrappers.ElectronicStructureMethod): High-level + method to use. + + allow_connectivity_changes (bool): Allow changes in connectivity, + although not (by definition) a + conformer it is useful to allow + + Raises: + (RuntimeError): If no conformers (with energies) can be generated + """ + logger.info("Finding lowest energy conformer") + + if self.n_atoms <= 2: + logger.warning( + "Cannot have conformers of a species with 2 atoms " "or fewer" + ) + return None + + lmethod = methods.method_or_default_lmethod(lmethod) + + method_log.add("Low energy conformers located with the") + self._generate_conformers() + + # For all generated conformers optimise with the low level of theory + method_string = f"and optimised using {lmethod.name}" + if hmethod is not None: + method_string += f" then with {hmethod.name}" + method_log.add(f"{method_string}.") + + self.conformers.optimise(method=lmethod) + self.conformers.prune(remove_no_energy=True) + + if hmethod is not None: + if Config.hmethod_sp_conformers: + # Use only single point energies on lmethod geometries + assert hmethod.keywords.low_sp is not None + assert hmethod is not None + self.conformers.single_point( + method=hmethod, keywords=hmethod.keywords.low_sp + ) + else: + # Otherwise run a full optimisation + self.conformers.optimise(hmethod) + + if not allow_connectivity_changes: + assert self.graph is not None, "Must have a graph" + self.conformers.prune_diff_graph(self.graph) + + self._set_lowest_energy_conformer() + logger.info(f"Lowest energy conformer found. E = {self.energy}") + return None + + def explicitly_solvate( + self, num: int = 10, solvent: Union[str, "Species", None] = None + ) -> None: + """ + Explicitly solvate this Molecule + + ---------------------------------------------------------------------- + Keyword Arguments: + + num (int): Number of solvent molecules to add around this molecule. + Default = 10 + + solvent (str | autode.species.Species | None): + + Raises: + (ValueError): If the solvent is not defined as a string or a + Species and the solvent of this species is not defined + """ + if solvent is None and self.solvent is None: + raise ValueError( + f"{self.name} must be solvated with a solvent " + "specified, as it is currently in the gas phase" + ) + + if isinstance(solvent, Species): + self.solvent = ExplicitSolvent(solvent=solvent, num=num) + + elif isinstance(solvent, str): + self.solvent = get_solvent(solvent, kind="explicit", num=num) + + elif ( + solvent is None + and self.solvent is not None + and self.solvent.is_implicit + ): + assert self.solvent is not None + self.solvent = self.solvent.to_explicit(num=num) + + else: + raise ValueError( + f"Unsupported solvent *{solvent}*. Must be " + f"either a string or a Species." + ) + + assert self.solvent is not None + print( + "WARNING: Explicit solvation is experimental is not implemented " + "beyond generating a single reasonable initial structure " + ) + self.solvent.randomise_around(self) + return None + + def calc_hessian( + self, + method: "Method", + keywords: Optional["Keywords"] = None, + numerical: bool = False, + use_central_differences: bool = False, + coordinate_shift: Union[float, val.Distance] = val.Distance( + 2e-3, units="Å" + ), + n_cores: Optional[int] = None, + ) -> None: + """ + Calculate the Hessian + + ----------------------------------------------------------------------- + Arguments: + method: Method to use to calculate the Hessian + + keywords: Keywords to use to calculate the Hessian, or gradient if + numerical = True + + numerical: Whether to do a numerical frequency calculation using + analytic gradients + + use_central_differences: Use central differences to calculate the + numerical Hessian. If True then use + df/dx = [f(x+h) - f(x-h)] / 2h + otherwise use single sided differences (faster + but less accurate) + df/dx = [f(x+h) - f(x)] / h + + coordinate_shift: Shift applied to each Cartesian coordinate (h) + in the calculation of the numerical Hessian + + n_cores: Number of cores to use for the calculation. If None + then default to Config.n_cores + """ + + if not method.implements(CalculationType.hessian): + logger.warning( + f"{method} does not implement a Hessian - using a " + f"numerical Hessian and overriding the keywords" + ) + numerical = True + + if not isinstance(keywords, GradientKeywords): + logger.warning(f"Using default gradient keywords for {method}") + keywords = method.keywords.grad + + if numerical: + if not isinstance(coordinate_shift, val.Distance): + logger.warning( + f"Calculating numerical Hessian with " + f"h = {coordinate_shift}. Assuming units of Å" + ) + coordinate_shift = val.Distance(coordinate_shift, units="Å") + + if keywords is None: + logger.info( + "Using default gradient keywords to evaluate " + "numerical Hessian" + ) + assert method.keywords.grad is not None + keywords = method.keywords.grad + + nhc = NumericalHessianCalculator( + self, + method=method, + keywords=GradientKeywords(keywords), + do_c_diff=use_central_differences, + shift=coordinate_shift, + n_cores=n_cores, + ) + nhc.calculate() + self.hessian = nhc.hessian + + if not numerical: + self._run_hess_calculation( + method=method, calc=None, keywords=keywords, n_cores=n_cores + ) + return None + + def has_identical_composition_as(self, species: "Species") -> bool: + """Does this species have the same chemical identity as another?""" + return self.sorted_atomic_symbols == species.sorted_atomic_symbols + + @property + def solvent_name(self) -> Optional[str]: + """ + Name of the solvent or None + + Returns: + (str | None): + """ + return None if self._solvent is None else self._solvent.name + + @solvent_name.setter + def solvent_name(self, value: Optional[str]) -> None: + """ + Set the solvent of this species given an optional name. Setting this to None + removes the solvent + + Arguments: + value (str | None): Name of the solvent to use + """ + if value is None: + self._solvent = None + else: + self._solvent = get_solvent(value, kind="implicit") + + # --- Method aliases --- + symmetry_number = sn diff --git a/autodE/source/autode/substitution.py b/autodE/source/autode/substitution.py new file mode 100644 index 0000000000000000000000000000000000000000..c4b66868feadb9efef3c0a4f0925d28968e39cca --- /dev/null +++ b/autodE/source/autode/substitution.py @@ -0,0 +1,301 @@ +from copy import deepcopy +import numpy as np +from numpy.linalg import norm as length +from autode.atoms import DummyAtom +from autode.mol_graphs import connected_components +from autode.log import logger + + +class SubstitutionCentre: + def __str__(self): + return ( + f"a_atom = {self.a_atom}, c_atom = {self.c_atom} " + f"x_atom = {self.x_atom}, a_atom_nns = {self.a_atom_nn}" + ) + + def set_attack_r0(self, species, shift_factor): + """Set the ideal distance between a and c atoms in a substitution + centre""" + + r0 = species.atoms.eqm_bond_distance(self.a_atom, self.c_atom) + self.r0_ac = shift_factor * r0 + return None + + def __init__(self, a_atom_idx, c_atom_idx, x_atom_idx, a_atom_nn_idxs): + """ + Substitution centre has the following structure:: + + H H H + | |/ + N-- H C -- Cl + / / + H H + + + where:: + + a_atom = N + c_atom = C + x_atom = Cl + a_atom_nn = H, H, H (bonded to N) + + all given as their atom indexes in a ReactantComplex + """ + + self.a_atom = a_atom_idx + self.c_atom = c_atom_idx + self.x_atom = x_atom_idx + self.a_atom_nn = a_atom_nn_idxs + + self.r0_ac = None + + +def get_substc_and_add_dummy_atoms(reactant, bond_rearrangement, shift_factor): + """Get all the substitution centers in a molecule. A substitution centre is + defined as atom that upon reaction has a bond made and broken + simultaneously + + --------------------------------------------------------------------------- + Arguments: + reactant (autode.complex.ReactantComplex): + + bond_rearrangement (autode.bond_rearrangement.BondRearrangement): + + shift_factor (float): The multiplier in the ideal A--C distance where + A is an attacking atom and C a substitution + centre + + Returns: + (tuple(list(autode.substitution.SubstitutionCentre), + autode.complex.ReactantComplex)): + """ + logger.info("Finding substitution centers in the reactant") + + subst_centers = [] + + for fbond in bond_rearrangement.fbonds: + for bbond in bond_rearrangement.bbonds: + if len(set(fbond).intersection(bbond)) == 0: + # If there are no common atoms between the forming and + # breaking bonds continue + continue + + # The attacked (c) atom is the intersection between the + # breaking and forming bonds + c_atom = list(set(fbond).intersection(bbond))[0] + + # The leaving group atom is the other atom in the breaking bond + x_atom = [ + atom_index for atom_index in bbond if atom_index != c_atom + ][0] + + # The attacked atom is the other atom in the forming bond + a_atom = [ + atom_index for atom_index in fbond if atom_index != c_atom + ][0] + + subst_center = SubstitutionCentre( + a_atom_idx=a_atom, + c_atom_idx=c_atom, + x_atom_idx=x_atom, + a_atom_nn_idxs=[nn for nn in reactant.graph.neighbors(a_atom)], + ) + subst_center.set_attack_r0( + species=reactant, shift_factor=shift_factor + ) + + subst_centers.append(subst_center) + + if len(subst_centers) == 0: + logger.info("No standard A - C - X substitution centres found") + + if ( + len(bond_rearrangement.bbonds) != 1 + or len(bond_rearrangement.fbonds) != 1 + ): + raise NotImplementedError + + # Add dummy atoms to the reactant to find e.g. SN2' reactions + add_dummy_atom(reactant, bond_rearrangement) + + # Once a dummy atom has been found then this function should find the + # *single* substitution centre + return get_substc_and_add_dummy_atoms( + reactant, bond_rearrangement, shift_factor + ) + + if any(atom.label == "D" for atom in reactant.atoms): + logger.info("Removing dummy X atom from bond rearrangement") + + d_atom_idxs = [ + i for i, atom in enumerate(reactant.atoms) if atom.label == "D" + ] + + # Reset the breaking bond list with only those not containing the + # dummy atom indexes + bbonds = [ + bbond + for bbond in bond_rearrangement.bbonds + if len(set(bbond).intersection(d_atom_idxs)) == 0 + ] + bond_rearrangement.bbonds = bbonds + + logger.info(f"Found {len(subst_centers)} substitution centers") + return subst_centers + + +def add_dummy_atom(reactant, bond_rearrangement): + """ + Add a dummy atom above or below the plane of the reactant as a temporary + X atom + + --------------------------------------------------------------------------- + Arguments: + reactant (autode.complex.ReactantComplex): + + bond_rearrangement (autode.bond_rearrangement.BondRearrangement): + """ + logger.info("Adding dummy X atom so a substitution center can be found") + + fbond = bond_rearrangement.fbonds[0] + bbond = bond_rearrangement.bbonds[0] + + components = connected_components(reactant.graph) + + if len(components) != 2: + raise NotImplementedError("Must have two components for dummy add") + + mol1_idxs, mol2_idxs = components + + # Find the central atom as the atom index that is in the forming bond but + # also contains all indexes of the breaking bond + if fbond[0] in mol1_idxs and all(idx in mol2_idxs for idx in bbond): + c_atom = fbond[1] + + else: + c_atom = fbond[0] + + # Nearest neighbours to the central atom used to generate the normal + # along which the dummy atom is placed + c_atom_nns = list(reactant.graph.neighbors(c_atom)) + + if len(c_atom_nns) < 2: + raise NotImplementedError("Cannot place dummy atom") + + cn1, cn2 = c_atom_nns[:2] + coords = reactant.coordinates + + # Calculate the normal from the vectors to two of the neighbours + position = np.cross( + coords[cn1] - coords[c_atom], coords[cn2] - coords[c_atom] + ) + position /= length(position) + + # Add the dummy atom to a position on the top/bottom face + logger.warning("Adding a dummy atom to the set of atoms") + reactant.atoms.append(DummyAtom(*position)) + + # Add the breaking bond to the bond rearrangement temporarily + bond_rearrangement.bbonds.append([c_atom, len(reactant.atoms) - 1]) + + return None + + +def attack_cost( + reactant, subst_centres, attacking_mol_idx, a=1.0, b=1.0, c=1.0, d=10.0 +): + """ + Calculate the 'attack cost' for a molecule attacking in e.g. a + substitution or elimination reaction:: + + C = Σ_ac a * (r_ac - r^0_ac)^2 + Σ_acx b * (1 - cos(θ)) + + Σ_acx c*(1 + cos(φ)) + Σ_ij d/r_ij^4 + + where:: + + cos(θ) = (v_ann • v_cx / |v_ann||v_cx|) + cos(φ) = (v_ca • v_cx / |v_ca||v_cx|) + + --------------------------------------------------------------------------- + Returns: + (float): Cost + """ + coords = reactant.coordinates + cost = 0 + + for subst_centre in subst_centres: + r_ac = reactant.distance(i=subst_centre.a_atom, j=subst_centre.c_atom) + + cost += a * (r_ac - subst_centre.r0_ac) ** 2 + + # Attack vector is the average of all the nearest neighbour atoms, + # unless it is flat + a_nn_coords = [ + coords[atom_index] - coords[subst_centre.a_atom] + for atom_index in subst_centre.a_atom_nn + ] + + if len(a_nn_coords) == 0: + # The attacking atom has no nearest neighbours thus take the + # attack vector to be a unit vector + v_ann = np.array([1.0, 0.0, 0.0]) + else: + v_ann = -np.average(np.array(a_nn_coords), axis=0) + + if length(v_ann) < 1e-1: + # Attacking atom is planar. Compute the perpendicular from two + # nearest neighbours + v_ann = np.cross( + coords[subst_centre.a_atom] + - coords[subst_centre.a_atom_nn[0]], + coords[subst_centre.a_atom] + - coords[subst_centre.a_atom_nn[1]], + ) + + v_cx = coords[subst_centre.x_atom] - coords[subst_centre.c_atom] + + # b(1 - cos(θ)) + cost += b * (1 - np.dot(v_ann, v_cx) / (length(v_ann) * length(v_cx))) + + v_ca = coords[subst_centre.a_atom] - coords[subst_centre.c_atom] + + # c(1 + cos(φ)) + cost += c * (1 + np.dot(v_ca, v_cx) / (length(v_ca) * length(v_cx))) + + repulsion = reactant.calc_repulsion(mol_index=attacking_mol_idx) + cost += d * repulsion + + return cost + + +def get_cost_rotate_translate(x, reactant, subst_centres, attacking_mol_idx): + """ + Get the cost for placing an attacking mol given a specified rotation and + translation + + --------------------------------------------------------------------------- + Arguments: + x (np.ndarray): Length 11 + + reactant (autode.complex.ReactantComplex): + + subst_centres (list(autode.substitution.SubstitutionCentre)): + + attacking_mol_idx (int): Index of the attacking molecule + + Returns: + (float): + """ + + moved_reactant = deepcopy(reactant) + moved_reactant.rotate_mol( + axis=x[:3], theta=x[3], mol_index=attacking_mol_idx + ) + + moved_reactant.translate_mol(vec=x[4:7], mol_index=attacking_mol_idx) + + moved_reactant.rotate_mol( + axis=x[7:10], theta=x[10], mol_index=attacking_mol_idx + ) + + return attack_cost(moved_reactant, subst_centres, attacking_mol_idx) diff --git a/autodE/source/autode/thermochemistry/__init__.py b/autodE/source/autode/thermochemistry/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..b0e1e20a88d695f9ebcdf21644c40b4849dfe608 --- /dev/null +++ b/autodE/source/autode/thermochemistry/__init__.py @@ -0,0 +1,4 @@ +from autode.thermochemistry.igm import calculate_thermo_cont +from autode.thermochemistry.symmetry import symmetry_number + +__all__ = ["calculate_thermo_cont", "symmetry_number"] diff --git a/autodE/source/autode/thermochemistry/igm.py b/autodE/source/autode/thermochemistry/igm.py new file mode 100644 index 0000000000000000000000000000000000000000..6528c57aa5fb7d59df0fbdd889a572692fc05b6d --- /dev/null +++ b/autodE/source/autode/thermochemistry/igm.py @@ -0,0 +1,574 @@ +""" +Thermochemistry calculation from frequencies and coordinates. Copied from +otherm (https://github.com/duartegroup/otherm) 16/05/2021. See +autode/common/thermochemistry.pdf for mathematical background + + +All calculations performed in SI units, for simplicity. +""" +import numpy as np +from enum import Enum +from typing import TYPE_CHECKING, Any +from autode.log import logger +from autode.config import Config +from autode.constants import Constants +from autode.values import FreeEnergyCont, EnthalpyCont, Frequency, Temperature + +if TYPE_CHECKING: + from autode.species.species import Species + + +class SIConstants: + """Constants in SI (International System of Units) units""" + + k_b = 1.38064852e-23 # J K-1 + h = 6.62607004e-34 # J s + c = 299792458 # m s-1 + + +class LFMethod(Enum): + """Method to treat low-frequency modes + + See Also: + autode.thermochemistry.igm.calculate_thermo_cont for citations for the + different methods. + """ + + igm = 0 + truhlar = 1 + grimme = 2 + minenkov = 3 + + +class _ThermoParams: + def __init__(self, default_sigma_r: int = 1, **kwargs: Any) -> None: + self.method = kwargs.get("lfm_method", LFMethod[Config.lfm_method]) + if isinstance(self.method, str): + self.method = LFMethod[self.method.lower()] + + self.T: float = kwargs["T"] + self.ss = kwargs.get("ss", Config.standard_state) + self.shift = Frequency(kwargs.get("freq_shift", Config.vib_freq_shift)) + self.w0 = Frequency(kwargs.get("w0", Config.grimme_w0)) + self.alpha = int(kwargs.get("alpha", Config.grimme_alpha)) + self.sigma_r = kwargs.get("sn", default_sigma_r) + + +def calculate_thermo_cont( + species: "Species", + temp: Temperature = Temperature(298.15, units="K"), + **kwargs: Any, +): + """ + Calculate and set the thermochemical contributions (Enthalpic, Free Energy) + of a species using a variant of the ideal gas model (RRHO). Appends + energies to species.energies. Default methods are set in + autode.config.Config. See references: + + [1] Chem. Eur. J. 2012, 18, 9955 + + [2] J. Phys. Chem. B, 2011, 115, 14556 + + [3] J. Comput. Chem., 2023, 44, 1807 + + --------------------------------------------------------------------------- + Arguments: + species (autode.species.Species): + + temp (autode.values.Temperature): Temperature in K. Default: 298.15 K + + Keyword Arguments: + lfm_method (str | LFMethod): Method used to calculate the molecular + entropy by treating the low frequency modes. + One of: {'igm', 'truhlar', 'grimme', 'minenkov'}. + Default: Config.lfm_method + + ss (str): Standard state at which the molecular entropy is calculated. + Should be 1M for a solution phase molecule and 1 atm for + a molecule in the gas phase. Default: Config.standard_state + + shift (float | Frequency): Frequency parameters for Truhlar's method, + if float then assumed cm-1 units. Only used + if method='truhlar'. + Default: Config.vib_freq_shift + + w0 (float | Frequency): ω0 parameter, if float then assumed cm-1 units + Default: Config.grimme_w0 + + alpha (int | float): α parameter in Grimme's qRRHO method. + Default: Config.grimme_w0 + + sn (int): The symmetry number, if not present then will default to + species.sn + + Raises: + (KeyError | ValueError): If frequencies are not defined + """ + params = _ThermoParams( + default_sigma_r=species.sn, + T=float(temp.to("K")) if isinstance(temp, Temperature) else temp, + **kwargs, + ) + + if species.n_atoms == 0: + logger.warning( + "Species had no atoms. Cannot calculate thermochemical " + "contributions (G_cont, H_cont)" + ) + return + + if species.frequencies is None and species.n_atoms > 1: + raise ValueError( + "Cannot calculate vibrational entropy/internal energy" + f" no frequencies present for {species.name}." + ) + + logger.info( + f"Calculating themochemistry with {params.method} at {params.T} K" + ) + + S = _entropy(species, params) + U = _internal_energy(species, params) + H = EnthalpyCont(U + SIConstants.k_b * params.T, units="J").to("Ha") + + # Add a method string for how this enthalpic contribution was calculated + H.method_str = _thermo_method_str(species, **kwargs) + species.energies.append(H) + + G = FreeEnergyCont(H.to("J") - params.T * S, units="J").to("Ha") + + # Method used to calculate the free energy is the same as the enthalpy.. + G.method_str = H.method_str + species.energies.append(G) + + return None + + +def _thermo_method_str(species: "Species", **kwargs: Any) -> str: + """ + Brief summary of the important methods used in evaluating the free energy + using entropy and enthalpy methods + + --------------------------------------------------------------------------- + Arguments: + species (autode.species.Species): + + **kwargs: + + Returns: + (str): + """ + string = "" + + if species.energy is not None: + string += f"{species.energy.method_str} " + + string += ( + f'{kwargs.get("ss", Config.standard_state)} standard state, ' + f'using a {kwargs.get("lfm_method", Config.lfm_method)} ' + f"treatment of low-frequency modes to the entropy." + ) + + return string + + +def _q_trans_igm(species: "Species", ss: str, temp: float) -> float: + """ + Calculate the translational partition function using the PIB model, + coupled with an effective volume + + --------------------------------------------------------------------------- + Arguments: + species (autode.species.Species): + ss (str): Standard state to use. One of: {1M, 1atm} + temp (float): Temperature in K + + Returns: + (float): Translational partition function q_trns + """ + + if ss.lower() == "1atm": + effective_volume = SIConstants.k_b * temp / Constants.atm_to_pa + + elif ss.lower() == "1m": + effective_volume = 1.0 / ( + Constants.n_a * (1.0 / Constants.dm_to_m) ** 3 + ) + + else: + raise ValueError( + f"Cannot calculate PIB partition function using a" + f' {ss} state. Only "1atm" and "1m" implemented' + ) + + q_trans = ( + 2.0 + * np.pi + * species.weight.to("kg") + * SIConstants.k_b + * temp + / SIConstants.h**2 + ) ** 1.5 * effective_volume + + return q_trans + + +def _q_rot_igm(species: "Species", temp: float, sigma_r: int) -> float: + """ + Calculate the rotational partition function using the IGM method. Uses the + rotational symmetry number, default = 1 + + --------------------------------------------------------------------------- + Arguments: + species (autode.species.Species): + temp (float): Temperature in K + sigma_r (int): Symmetry number e.g. 2 for water + + Returns: + (float): Rotational partition function q_rot + """ + + if species.n_atoms == 1: + return 1 + + assert species.com is not None, "Must have a COM" + assert species.atoms is not None, "Must have atoms" + assert species.moi is not None, "Must have a moment of inertia" + + if species.is_linear(): + com = species.com.to("m") + i_val = sum( + atom.mass.to("kg") * np.linalg.norm(atom.coord.to("m") - com) ** 2 + for atom in species.atoms + ) + + return ( + temp + * 8 + * np.pi**2 + * SIConstants.k_b + * i_val + / (sigma_r * SIConstants.h**2) + ) + + # otherwise a polyatomic.. + i_mat = species.moi.to("kg m^2") + omega_diag = SIConstants.h**2 / ( + 8.0 * np.pi**2 * SIConstants.k_b * np.diagonal(i_mat) + ) + + return temp**1.5 / sigma_r * np.sqrt(np.pi / np.prod(omega_diag)) + + +def _s_trans_pib(species: "Species", ss: str, temp: float) -> float: + """ + Calculate the translational entropy using a particle in a box model + + --------------------------------------------------------------------------- + Arguments: + species (autode.species.Species): + ss (str): Standard state to use. One of: {1M, 1atm}. For calculating + the effective box size in the q_trans calculation + temp (float): Temperature in K + + Returns: + (float): S_trans + """ + + q_trans = _q_trans_igm(species, ss=ss, temp=temp) + return SIConstants.k_b * (np.log(q_trans) + 1.0 + 1.5) + + +def _s_rot_rr(species: "Species", temp: float, sigma_r: int) -> float: + """ + Calculate the rigid rotor (RR) entropy + + --------------------------------------------------------------------------- + Arguments: + species (autode.species.Species): + temp (float): Temperature in K + + Returns: + (float): S_rot + """ + + if species.n_atoms == 1: + return 0 + + q_rot = _q_rot_igm(species, temp=temp, sigma_r=sigma_r) + + if species.is_linear(): + return SIConstants.k_b * (np.log(q_rot) + 1.0) + + else: + return SIConstants.k_b * (np.log(q_rot) + 1.5) + + +def _igm_s_vib(species: "Species", temp: float) -> float: + """ + Calculate the entropy of a molecule according to the Ideal Gas Model (IGM) + RRHO method + + --------------------------------------------------------------------------- + Arguments: + species (autode.species.Species): + temp (float): Temperature in K + + Returns: + (float): S_vib + """ + assert species.vib_frequencies is not None, "Must have frequenecies" + s = 0.0 + + for freq in species.vib_frequencies: + x = freq.real.to("hz") * SIConstants.h / (SIConstants.k_b * temp) + s += SIConstants.k_b * ( + (x / (np.exp(x) - 1.0)) - np.log(1.0 - np.exp(-x)) + ) + + return float(s) + + +def _truhlar_s_vib( + species: "Species", temp: float, shift_freq: Frequency +) -> float: + """ + Calculate the entropy of a molecule according to the Truhlar's method of + shifting low frequency modes + + --------------------------------------------------------------------------- + Arguments: + species (autode.species.Species): + temp (float): Temperature in K + shift_freq (float): Shift all frequencies to this value + + Returns: + (float): S_vib in J K-1 mol-1 + """ + assert species.vib_frequencies is not None, "Must have frequenecies" + s = 0 + + shift_cm = float(shift_freq.to("cm-1")) + + for freq in species.vib_frequencies: + # Threshold lower bound of the frequency + freq_cm = max(float(freq.to("cm-1").real), shift_cm) + + x = freq_cm * Constants.c_in_cm * SIConstants.h / SIConstants.k_b + s += SIConstants.k_b * ( + ((x / temp) / (np.exp(x / temp) - 1.0)) + - np.log(1.0 - np.exp(-x / temp)) + ) + + return float(s) + + +def _grimme_w(omega_0: float, freq: float, alpha: int) -> float: + assert ( + abs(freq) < 6000 and abs(omega_0) < 6000 + ), "Units may be wrong - expecing cm-1" + return 1.0 / (1.0 + (omega_0 / freq) ** alpha) + + +def _grimme_s_vib( + species: "Species", temp: float, omega_0: Frequency, alpha: int +) -> float: + """ + Calculate the entropy according to Grimme's qRRHO method of RR-HO + interpolation in Chem. Eur. J. 2012, 18, 9955 + + --------------------------------------------------------------------------- + Arguments: + species (autode.species.Species): + temp (float): Temperature in K + omega_0 (float | Frequency): ω0 parameter (cm-1) + alpha (float): α parameter + + Returns: + (float): S_vib + """ + assert species.vib_frequencies is not None, "Must have frequenecies" + assert species.moi is not None, "Must have a moment of inertia" + + s = 0.0 + w0 = float(omega_0.to("cm-1")) if hasattr(omega_0, "to") else omega_0 + + # Average I = (I_xx + I_yy + I_zz) / 3.0 + b_avg = np.trace(species.moi.to("kg m^2")) / 3.0 + + for freq in species.vib_frequencies: + omega = float(freq.real.to("hz")) + + mu = SIConstants.h / (8.0 * np.pi**2 * omega) + mu_prime = (mu * b_avg) / (mu + b_avg) + + x = omega * SIConstants.h / (SIConstants.k_b * temp) + s_v = SIConstants.k_b * ( + (x / (np.exp(x) - 1.0)) - np.log(1.0 - np.exp(-x)) + ) + + factor = ( + 8.0 * np.pi**3 * mu_prime * SIConstants.k_b * temp + ) / SIConstants.h**2 + s_r = SIConstants.k_b * (0.5 + np.log(np.sqrt(factor))) + + w = _grimme_w(omega_0=w0, freq=freq, alpha=alpha) + + s += w * s_v + (1.0 - w) * s_r + + return float(s) + + +def _entropy(species: "Species", params: _ThermoParams) -> float: + """ + Calculate the entropy + + --------------------------------------------------------------------------- + Arguments: + species (autode.species.Species): + params (autode.thermochemistry.igm._ThermoParams): + + Returns: + (float): S in SI units + + Raises: + (NotImplementedError): + """ + logger.info(f"Calculating molecular entropy. σ_R = {params.sigma_r}") + temp = params.T + + # Translational entropy component + s_trans = _s_trans_pib(species, ss=params.ss, temp=params.T) + + if species.n_atoms < 2: + # A molecule one or no atoms has no rotational/vibrational DOF + return s_trans + + # Rotational entropy component + s_rot = _s_rot_rr(species, temp=temp, sigma_r=params.sigma_r) + + # Vibrational entropy component + if params.method == LFMethod.igm: + s_vib = _igm_s_vib(species, temp) + + elif params.method == LFMethod.truhlar: + s_vib = _truhlar_s_vib(species, temp, shift_freq=params.shift) + + elif ( + params.method == LFMethod.grimme or params.method == LFMethod.minenkov + ): + s_vib = _grimme_s_vib( + species, temp, omega_0=params.w0, alpha=params.alpha + ) + + else: + raise NotImplementedError(f"Unrecognised method: {params.method}") + + logger.info( + f"S_trans = {s_trans*Constants.n_a:.3f} J K-1 mol-1\n" + f"S_rot = {s_rot*Constants.n_a:.3f} J K-1 mol-1\n" + f"S_vib = {s_vib*Constants.n_a:.3f} J K-1 mol-1\n" + f"S_elec = 0.0" + ) + + return s_trans + s_rot + s_vib + + +def _zpe(species: "Species"): + """ + Calculate the zero point energy of a molecule, contributed to by the real + (positive) frequencies + + --------------------------------------------------------------------------- + Arguments: + species (autode.species.Species): + + Returns: + (float): E_ZPE in SI units + """ + + if species.n_atoms < 2: + return 0.0 + + assert species.vib_frequencies is not None, "Must have frequenecies" + + zpe = 0.0 + for freq in species.vib_frequencies: + zpe += 0.5 * SIConstants.h * float(freq.real.to("hz")) + + return float(zpe) + + +def _internal_vib_energy(species: "Species", params: _ThermoParams) -> float: + """ + Calculate the internal energy from vibrational motion within the IGM + + --------------------------------------------------------------------------- + Arguments: + species (autode.species.Species): + params (_ThermoParams): + + Returns: + (float): U_vib in SI units + """ + assert species.vib_frequencies is not None, "Must have frequenecies" + + temp = params.T + w0_cm = float(params.w0.to("cm-1")) + + u = 0.0 # Total internal vibrational energy + u_r = 0.5 * SIConstants.k_b * temp # Free rotor internal energy + + # Final 6 vibrational frequencies are translational/rotational + for freq in species.vib_frequencies: + freq_cm = float(freq.real.to("cm-1")) + x = freq_cm * Constants.c_in_cm * SIConstants.h / SIConstants.k_b + + u_v = SIConstants.k_b * x * (1.0 / (np.exp(x / temp) - 1.0)) + if params.method == LFMethod.minenkov: + w = _grimme_w(omega_0=w0_cm, freq=freq_cm, alpha=params.alpha) + u += w * u_v + (1 - w) * u_r + else: + u += u_v + + return float(u) + + +def _internal_energy(species: "Species", params: _ThermoParams) -> float: + """ + Calculate the internal energy of a molecule + + --------------------------------------------------------------------------- + Arguments: + species (autode.species.Species): + params (_ThermoParams): + + Returns: + (float): U_cont in SI units + """ + temp = params.T + e_trns = 1.5 * SIConstants.k_b * temp + + if species.n_atoms < 2: + # A molecule one or no atoms has no rotational/vibrational DOF + return e_trns + + if species.is_linear(): + # Linear molecules only have two rotational degrees of freedom -> RT + e_rot = SIConstants.k_b * temp + + else: + # From equipartition with 3 DOF -> 3/2 RT contribution to the energy + e_rot = 1.5 * SIConstants.k_b * temp + + zpe = _zpe(species) + e_vib = _internal_vib_energy(species, params) + + logger.info( + f"ZPE = {zpe*Constants.n_a/1E3:.3f} kJ mol-1\n" + f"E_trans = {e_trns*Constants.n_a/1E3:.3f} kJ mol-1\n" + f"E_rot = {e_rot*Constants.n_a/1E3:.3f} kJ mol-1\n" + f"E_vib = {e_vib*Constants.n_a/1E3:.3f} kJ mol-1" + ) + + return zpe + e_trns + e_rot + e_vib diff --git a/autodE/source/autode/thermochemistry/symmetry.py b/autodE/source/autode/thermochemistry/symmetry.py new file mode 100644 index 0000000000000000000000000000000000000000..5362909f246ec745925ec157bf3fee07dfa9a9b5 --- /dev/null +++ b/autodE/source/autode/thermochemistry/symmetry.py @@ -0,0 +1,271 @@ +import numpy as np +from scipy.spatial import distance_matrix +from autode.geom import get_rot_mat_euler + + +def strip_identical_and_inv_axes(axes, sim_axis_tol): + """ + For a list of axes remove those which are similar to within some distance + tolerance, or are inverses to within that tolerance + + --------------------------------------------------------------------------- + Arguments: + axes: list of axes + sim_axis_tol: distance tolerance in Å + + Returns: + (list(np.ndarray)): + """ + + unique_possible_axes = [] + + for i in range(len(axes)): + unique = True + for unique_axis in unique_possible_axes: + if np.linalg.norm(axes[i] - unique_axis) < sim_axis_tol: + unique = False + if np.linalg.norm(-axes[i] - unique_axis) < sim_axis_tol: + unique = False + if unique: + unique_possible_axes.append(axes[i]) + + return unique_possible_axes + + +def get_possible_axes(coords, max_triple_dist=2.0, sim_axis_tol=0.1): + r""" + Possible rotation axes in a molecule. Currently limited to average vectors + and cross products i.e.:: + + Y Y ---> + / \ / \ + X Y X Z + + | + | + , + + --------------------------------------------------------------------------- + Arguments: + coords (np.ndarray): + + max_triple_dist (float): + + sim_axis_tol (float): + + Returns: + (list(np.ndarray)): + """ + + possible_axes = [] + n_atoms = len(coords) + + for i in range(n_atoms): + for j in range(n_atoms): + if i > j: # For the unique pairs add the i–j vector + vec = coords[j] - coords[i] + vec /= np.linalg.norm(vec) + possible_axes.append(vec) + + for k in range(n_atoms): + # Triple must not have any of the same atoms + if any((i == j, i == k, j == k)): + continue + + vec1 = coords[j] - coords[i] + vec2 = coords[k] - coords[i] + if all( + np.linalg.norm(vec) < max_triple_dist + for vec in (vec1, vec2) + ): + avg_vec = (vec1 + vec2) / 2.0 + possible_axes.append(avg_vec / np.linalg.norm(avg_vec)) + + perp_vec = np.cross(vec1, vec2) + possible_axes.append(perp_vec / np.linalg.norm(perp_vec)) + + unique_possible_axes = strip_identical_and_inv_axes( + possible_axes, sim_axis_tol + ) + + return unique_possible_axes + + +def is_same_under_n_fold( + pcoords, axis, n, m=1, tol=0.25, excluded_pcoords=None +): + """ + Does applying an n-fold rotation about an axis generate the same structure + back again? + + --------------------------------------------------------------------------- + Arguments: + pcoords (np.ndarray): shape = (n_unique_atom_types, n_atoms, 3) + + axis (np.ndarray): shape = (3,) + + n (int): n-fold of this rotation + + m (int): Apply this n-fold rotation m times + + tol (float): + + excluded_pcoords (list): + + Returns: + (bool): + """ + n_unique, n_atoms, _ = pcoords.shape + rotated_coords = np.array(pcoords, copy=True) + + rot_mat = get_rot_mat_euler(axis, theta=(2.0 * np.pi * m / n)) + + excluded = [False for _ in range(n_unique)] + + for i in range(n_unique): + # Rotate these coordinates + rotated_coords[i] = rot_mat.dot(rotated_coords[i].T).T + + dist_mat = distance_matrix(pcoords[i], rotated_coords[i]) + + # If all elements are identical then carry on with the next element + if np.linalg.norm(dist_mat) < tol: + continue + + # If the RMS between the closest pairwise distance for each atom is + # above the threshold then these structures are not the same + if np.linalg.norm(np.min(dist_mat, axis=1)) > tol: + return False + + if excluded_pcoords is not None: + # If these rotated coordinates are similar to those on the excluded + # list then these should not be considered identical + if any( + np.linalg.norm(rotated_coords[i] - pcoords[i]) < tol + for pcoords in excluded_pcoords + ): + excluded[i] = True + + # This permutation has already been found - return False even though + # it's the same, because there is an excluded list + if all(excluded): + return False + + # Add to a list of structures that have already been generated by rotations + if excluded_pcoords is not None: + excluded_pcoords.append(rotated_coords) + + return True + + +def cn_and_axes(species, pcoords, max_n, dist_tol): + """ + Find the highest symmetry rotation axis + + --------------------------------------------------------------------------- + Arguments: + species (autode.species.Species): + + max_n (int): + + dist_tol (float): + + Returns: + (dict(int: np.ndarray)): + """ + axes = get_possible_axes(coords=species.coordinates) + + # Cn numbers and their associated axes + cn_assos_axes = {i: [] for i in range(2, max_n + 1)} + + for axis in axes: + # Minimum n-fold rotation is 2 + for n in range(2, max_n + 1): + if is_same_under_n_fold(pcoords, axis, n=n, tol=dist_tol): + cn_assos_axes[n].append(axis) + + return cn_assos_axes + + +def create_pcoords(species): + """ + Return a tensor where the first dimension is the size of the number of + unique atom types in a molecule, the second, the atoms of that type + and the third the number of dimensions in the coordinate space (3) + + :return: (np.ndarray) shape (n, m, 3) + """ + atom_symbols = list(set(atom.label for atom in species.atoms)) + n_symbols = len(atom_symbols) + + pcoords = np.zeros(shape=(n_symbols, species.n_atoms, 3)) + + for i in range(n_symbols): + for j in range(species.n_atoms): + # Atom symbol needs to match the leading dimension + if species.atoms[j].label != atom_symbols[i]: + continue + + pcoords[i, j, :] = species.atoms[j].coord + + return pcoords + + +def symmetry_number(species, max_n_fold_rot_searched=6, dist_tol=0.25): + """ + Calculate the symmetry number of a molecule. See: + Theor Chem Account (2007) 118:813–826. 10.1007/s00214-007-0328-0 + + --------------------------------------------------------------------------- + Arguments: + species (autode.atoms.Species): + + Keyword Arguments: + max_n_fold_rot_searched (int): + + dist_tol (float): Distance tolerance (Å) + + Returns: + (int): + """ + species.translate(vec=-species.com) + pcoords = create_pcoords(species) + + # Get the highest Cn-fold rotation axis + cn_axes = cn_and_axes(species, pcoords, max_n_fold_rot_searched, dist_tol) + + # If there are no C2 or greater axes then this molecule is C1 → σ=1 + if all(len(cn_axes[n]) == 0 for n in cn_axes.keys()): + return 1 + + sigma_r = 1 # Already has E symmetry + + added_pcoords = [] + + # For every possible axis apply C2, C3...C_n_max rotations + for n, axes in cn_axes.items(): + for axis in axes: + # Apply this rotation m times e.g. once for a C2 etc. + for m in range(1, n): + # If the structure is the same but and has *not* been generated + # by another rotation increment the symmetry number by 1 + if is_same_under_n_fold( + pcoords, + axis, + n=n, + m=m, + tol=dist_tol, + excluded_pcoords=added_pcoords, + ): + sigma_r += 1 + + if species.is_linear(): + # There are perpendicular C2s the point group is D∞h + if sigma_r > 2: + return 2 + + # If not then C∞v and the symmetry number is 1 + else: + return 1 + + return sigma_r diff --git a/autodE/source/autode/transition_states/__init__.py b/autodE/source/autode/transition_states/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..de13b76609a76f3e23d9ac6b41cf7478c7d73ea9 --- /dev/null +++ b/autodE/source/autode/transition_states/__init__.py @@ -0,0 +1,5 @@ +from autode.transition_states.ts_guess import TSguess +from autode.transition_states.transition_state import TransitionState +from autode.transition_states.transition_states import TransitionStates + +__all__ = ["TSguess", "TransitionState", "TransitionStates"] diff --git a/autodE/source/autode/transition_states/base.py b/autodE/source/autode/transition_states/base.py new file mode 100644 index 0000000000000000000000000000000000000000..7dfbcd57fb5e1abadc2f963f591cbb3d56a133d8 --- /dev/null +++ b/autodE/source/autode/transition_states/base.py @@ -0,0 +1,584 @@ +from abc import ABC +import numpy as np +import autode.exceptions as ex + +from typing import Optional, TYPE_CHECKING + +from autode.atoms import metals +from autode.config import Config +from autode.geom import calc_rmsd +from autode.constraints import DistanceConstraints +from autode.log import logger +from autode.methods import get_hmethod, get_lmethod +from autode.mol_graphs import make_graph, species_are_isomorphic +from autode.species.species import Species +from autode.exceptions import AutodeException + + +if TYPE_CHECKING: + from autode.species import Species + from autode.bond_rearrangement import BondRearrangement + from autode.atoms import Atoms + + +class TSbase(Species, ABC): + r""" + Base transition state class. e.g.:: + + H H + \ / + F ------- C --------Cl + r1 | r2 r1 = 2.0 Å + H r2 = 2.2 Å + """ + + def __init__( + self, + atoms: "Atoms", + reactant: Optional["Species"] = None, + product: Optional["Species"] = None, + name: str = "ts_guess", + charge: int = 0, + mult: int = 1, + bond_rearr: Optional["BondRearrangement"] = None, + solvent_name: Optional[str] = None, + ): + """ + Parent transition state class + + ----------------------------------------------------------------------- + Arguments: + atoms: Atoms with positions and symbols + + reactant: If None then mode checking will not be available + + product: If None then mode checking will not be available + + name: Name of this TS guess + + charge: Total charge on this TS guess, in units of e + + mult: Spin multiplicity (2S+1) for S unpaired electrons + + bond_rearr: Bond rearrangement associated with the transformation + reactant -> product + + solvent_name: Name of the solvent + """ + super().__init__( + name=name, + atoms=atoms, + charge=charge if reactant is None else reactant.charge, + mult=mult if reactant is None else reactant.mult, + solvent_name=solvent_name, + ) + + self.reactant = reactant + self.product = product + self.bond_rearrangement = bond_rearr + + self._init_graph() + self._init_solvent() + + def __eq__(self, other): + """Equality of this TS to another""" + return ( + isinstance(other, TSbase) + and calc_rmsd(self.coordinates, other.coordinates) < 1e-6, + super().__eq__(other), + ) + + def _init_graph(self) -> None: + """Set the molecular graph for this TS object from the reactant""" + + if self.reactant is not None: + logger.warning(f"Setting the graph of {self.name} from reactants") + assert self.reactant.graph is not None + self._graph = self.reactant.graph.copy() + + return None + + def _init_solvent(self) -> None: + """Initialise the solvent on this TS guesss""" + + if ( + self.reactant is not None and self.product is not None + ) and self.reactant.solvent != self.product.solvent: + raise ValueError( + "Cannot initialise a TS guess with reactants " + "and products immersed in different solvents" + ) + + if self.reactant is not None: + if ( + self.solvent is not None + and self.reactant.solvent != self.solvent + ): + raise ValueError( + "Reactant does not have the same solvent as " + "the TS guess" + ) + + if ( + self.solvent is None # No solvent has been given explicitly + and self.reactant.solvent is not None + ): + logger.info( + "Setting TS guess solvent from reactant state to " + f"{self.reactant.solvent}" + ) + self.solvent = self.reactant.solvent + + return None + + @property + def has_imaginary_frequencies(self) -> bool: + """Does this possible transition state have any imaginary modes?""" + return self.imaginary_frequencies is not None + + @property + def could_have_correct_imag_mode(self) -> bool: + """ + Determine if a point on the PES could have the correct imaginary mode. + This must have + + (0) An imaginary frequency (quoted as negative in most EST codes) + (1) The most negative(/imaginary) is more negative that a threshold, + which is defined as autode.config.Config.min_imag_freq + + ----------------------------------------------------------------------- + Returns: + (bool): + + Raises: + (ValueError): If the bond-rearrangement is not set, so that there + is no chance of determining the right mode + """ + if self.bond_rearrangement is None: + raise ValueError( + "Do not have a bond rearrangement - cannot " + "check the imaginary mode" + ) + + if self.hessian is None: + logger.info("Calculating the hessian..") + self._run_hess_calculation(method=get_hmethod()) + + imag_freqs = self.imaginary_frequencies + + if imag_freqs is None: + logger.warning( + "Hessian had no imaginary modes. Do not have the " + "correct mode" + ) + return False + + logger.info(f"Hessian had {len(imag_freqs)} imaginary modes") + if imag_freqs[0] > Config.min_imag_freq: + logger.warning("Imaginary modes were too small to be significant") + return False + + # Check very conservatively for the correct displacement + if not self.imag_mode_has_correct_displacement( + delta_threshold=0.05, req_all=False + ): + logger.warning("Species does not have the correct imaginary mode") + return False + + logger.info("Species could have the correct imaginary mode") + return True + + @property + def has_correct_imag_mode(self) -> bool: + """Check that the imaginary mode is 'correct' set the calculation + (hessian or optts) + + ----------------------------------------------------------------------- + Returns: + (bool): + + Raises: + (ValueError): If reactants and products aren't set, thus cannot + run a quick reaction profile + """ + + # Run a fast check on whether it's likely the mode is correct + if not self.could_have_correct_imag_mode: + return False + + if self.imag_mode_has_correct_displacement(req_all=True): + logger.info( + "Displacement of the active atoms in the imaginary " + "mode bond forms and breaks the correct bonds" + ) + return True + + # Perform displacements over the imaginary mode to ensure the mode + # connects reactants and products + if self.imag_mode_links_reactant_products(disp_mag=1.0): + logger.info("Imaginary mode does link reactants and products") + return True + + logger.warning("Species does *not* have the correct imaginary mode") + return False + + def imag_mode_has_correct_displacement( + self, + disp_mag: float = 1.0, + delta_threshold: float = 0.3, + req_all: bool = True, + ) -> bool: + """ + Check whether the imaginary mode in a calculation with a hessian forms + and breaks the correct bonds + + ----------------------------------------------------------------------- + Keyword Arguments: + disp_mag (float): + + delta_threshold (float): Required ∆r on a bond for the bond to be + considered as forming + req_all (bool): Require all the bonds to have the correct displacements + + Returns: + (bool): + """ + assert self.bond_rearrangement, "Must have a bond rearrangement" + + logger.info( + "Checking displacement on imaginary mode forms the correct" + " bonds" + ) + + f_species = displaced_species_along_mode( + self, mode_number=6, max_atom_disp=0.5, disp_factor=disp_mag + ) + + b_species = displaced_species_along_mode( + self, mode_number=6, max_atom_disp=0.5, disp_factor=-disp_mag + ) + + # Be conservative with metal complexes - what even is a bond.. + if imag_mode_generates_other_bonds( + self, f_species, b_species, allow_mx=True + ): + logger.warning( + "Imaginary mode generates bonds that are not active" + ) + return False + + # Product could be either the forward displaced molecule or the + # backwards equivalent + for product in (f_species, b_species): + fbond_bbond_correct_disps = [] + + for fbond in self.bond_rearrangement.fbonds: + ts_dist = self.distance(*fbond) + p_dist = product.distance(*fbond) + + # Displaced distance towards products should be shorter than + # the distance at the TS if the bond is forming + if ts_dist - p_dist > delta_threshold: + fbond_bbond_correct_disps.append(True) + + else: + fbond_bbond_correct_disps.append(False) + + for bbond in self.bond_rearrangement.bbonds: + ts_dist = self.distance(*bbond) + p_dist = product.distance(*bbond) + + # Displaced distance towards products should be longer than the + # distance at the TS if the bond is breaking + if p_dist - ts_dist > delta_threshold: + fbond_bbond_correct_disps.append(True) + + else: + fbond_bbond_correct_disps.append(False) + + logger.info( + f"List of forming and breaking bonds that have the " + f"correct properties {fbond_bbond_correct_disps}" + ) + + if all(fbond_bbond_correct_disps) and req_all: + logger.info( + f"{product.name} afforded the correct bond " + f"forming/breaking reactants -> products" + ) + return True + + if not req_all and any(fbond_bbond_correct_disps): + logger.info("At least one bond had the correct displacement") + return True + + logger.warning( + "Displacement along the imaginary mode did not form " + "and break the correct bonds" + ) + return False + + def imag_mode_links_reactant_products(self, disp_mag: float = 1.0) -> bool: + """Displaces atoms along the imaginary mode forwards (f) and backwards (b) + to see if products and reactants are made + + ----------------------------------------------------------------------- + Keyword Arguments: + disp_mag (float): Distance to be displaced along the imag mode + (default: 1.0 Å) + + Returns: + (bool): if the imag mode is correct or not + """ + logger.info( + "Displacing along imag modes to check that the TS links " + "reactants and products" + ) + if self.reactant is None or self.product is None: + raise ValueError( + "Could not check imaginary mode – reactants " + " and/or products not set " + ) + + # Generate and optimise conformers with the low level of theory + try: + self.reactant.populate_conformers() + self.product.populate_conformers() + except NotImplementedError: + logger.error( + "Could not generate conformers of reactant/product(s)" + " QRC will run without conformers" + ) + + # Get the species by displacing forwards along the mode + f_mol = displaced_species_along_mode( + self, mode_number=6, disp_factor=disp_mag, max_atom_disp=0.2 + ) + f_mol.name = f"{self.name}_forwards" + + # and the same backwards + b_mol = displaced_species_along_mode( + self, mode_number=6, disp_factor=-disp_mag, max_atom_disp=0.2 + ) + b_mol.name = f"{self.name}_backwards" + assert f_mol.graph and b_mol.graph, "Must have graphs" + + # The high and low level methods may not have the same minima, so + # optimise and recheck isomorphisms + for method in (get_hmethod(), get_lmethod()): + for mol in (f_mol, b_mol): + try: + mol.optimise( + method=method, + keywords=method.keywords.low_opt, + reset_graph=True, + ) + + except ex.CalculationException: + logger.error( + f"Failed to optimise {mol.name} with " + f"{method}. Assuming no link" + ) + return False + + if forward_backward_isomorphic_to_reactant_product( + f_mol, b_mol, self.reactant, self.product + ): + return True + + logger.info(f"Forwards displaced edges {f_mol.graph.edges}") + logger.info(f"Backwards displaced edges {b_mol.graph.edges}") + return False + + @property + def active_bond_constraints(self) -> DistanceConstraints: + """ + Set all the distance constraints required in an optimisation as the + active bonds + + ----------------------------------------------------------------------- + Returns: + (dict): Keyed with atom indexes for the active atoms (tuple) and + equal to the constrained value + """ + assert self.graph is not None, "Must have a molecular graph" + constraints = DistanceConstraints() + + for edge in self.graph.edges: + if self.graph.edges[edge]["active"]: + constraints[edge] = self.distance(*edge) + + return constraints + + +def displaced_species_along_mode( + species: Species, + mode_number: int, + disp_factor: float = 1.0, + max_atom_disp: float = 99.9, +) -> Species: + """ + Displace the geometry along a normal mode with mode number indexed from 0, + where 0-2 are translational normal modes, 3-5 are rotational modes and 6 + is the largest magnitude imaginary mode (if present). To displace along + the second imaginary mode we have mode_number=7 + + --------------------------------------------------------------------------- + Arguments: + species (autode.species.Species): + + mode_number (int): Mode number to displace along + + disp_factor (float): Distance to displace (default: {1.0}) + + max_atom_disp (float): Maximum displacement of any atom (Å) + + Returns: + (autode.species.Species): + + Raises: + (autode.exceptions.CouldNotGetProperty | autode.exceptions.AutodeException): + """ + logger.info(f"Displacing along mode {mode_number} in {species.name}") + + mode_disp_coords = species.normal_mode(mode_number) + if mode_disp_coords is None: + raise AutodeException( + "Could not get a displaced species. No normal mode " + "could be found" + ) + + coords = species.coordinates + disp_coords = coords.copy() + disp_factor * mode_disp_coords + + # Ensure the maximum displacement distance any single atom is below the + # threshold (max_atom_disp), by incrementing backwards in steps of 0.05 Å, + # for disp_factor = 1.0 Å + for _ in range(20): + if ( + np.max(np.linalg.norm(coords - disp_coords, axis=1)) + < max_atom_disp + ): + break + + disp_coords -= (disp_factor / 20) * mode_disp_coords + + disp_species = species.new_species(name=f"{species.name}_disp") + disp_species.coordinates = disp_coords + + return disp_species + + +def imag_mode_generates_other_bonds( + ts: TSbase, f_species: Species, b_species: Species, allow_mx: bool = False +) -> bool: + """ + Determine if the forward or backwards displaced molecule break or make + bonds that aren't in all the active bonds bond_rearrangement.all. Will be + fairly conservative here + + --------------------------------------------------------------------------- + Arguments: + ts (autode.transition_states.base.TSbase): + + f_species (autode.species.Species): Forward displaced species + + b_species (autode.species.Species): Backward displaced species + + allow_mx (bool): Allow any metal-X bonds where X is another element + + Returns: + (bool): + """ + + _ts: TSbase = ts.copy() + assert _ts.graph is not None, "TS must have a molecular graph" + + for species in (_ts, f_species, b_species): + make_graph(species, rel_tolerance=0.3) + + for product in (f_species, b_species): + assert product.graph is not None, "Must have a graph for product" + + new_bonds_in_product = set( + [ + bond + for bond in product.graph.edges + if bond not in _ts.graph.edges + ] + ) + + if allow_mx: + new_bonds_in_product = set( + [ + (i, j) + for i, j in new_bonds_in_product + if _ts.atoms[i].label not in metals + and _ts.atoms[j].label not in metals + ] + ) + + br = _ts.bond_rearrangement + assert br is not None, "Must have a bond rearrangement" + + if not set(a for b in new_bonds_in_product for a in b).issubset( + set(br.active_atoms) + ): + logger.warning(f"New bonds in product: {new_bonds_in_product}") + logger.warning( + f"Active bonds: {br.all}. Active atoms {br.active_atoms}" + ) + return True + + logger.info("Imaginary mode does not generate any other unwanted bonds") + return False + + +def forward_backward_isomorphic_to_reactant_product( + forwards: Species, + backwards: Species, + reactant: "Species", + product: "Species", +) -> bool: + """ + Are the forward/backward displaced species isomorphic to + reactants/products? + + --------------------------------------------------------------------------- + Arguments: + forwards (autode.species.Species): + + backwards (autode.species.Species): + + reactant (autode.species.ReactantComplex): + + product (autode.species.ProductComplex): + + Returns: + (bool): + """ + + if any(mol.atoms is None for mol in (forwards, backwards)): + logger.warning( + "Atoms not set in the output. " "Cannot calculate isomorphisms" + ) + return False + + if species_are_isomorphic(backwards, reactant) and species_are_isomorphic( + forwards, product + ): + logger.info( + "Forwards displacement lead to products " "and backwards reactants" + ) + return True + + if species_are_isomorphic(forwards, reactant) and species_are_isomorphic( + backwards, product + ): + logger.info( + "Backwards displacement lead to products " + "and forwards to reactants" + ) + return True + + return False diff --git a/autodE/source/autode/transition_states/lib/template0.txt b/autodE/source/autode/transition_states/lib/template0.txt new file mode 100644 index 0000000000000000000000000000000000000000..50ca6491f5874dc1f98281948dcf881429e95a8d --- /dev/null +++ b/autodE/source/autode/transition_states/lib/template0.txt @@ -0,0 +1,22 @@ +TS template generated by autode v.1.2.0 on 2021-10-07 + +solvent: None +charge: 0 +multiplicity: 2 +nodes: + 1: atom_label=C + 6: atom_label=H + 2: atom_label=C + 0: atom_label=C + 7: atom_label=H + 9: atom_label=H + 8: atom_label=H + +edges: + 1-6: pi=False active=True distance=1.2943 + 1-0: pi=False active=False + 1-7: pi=False active=False + 1-2: pi=False active=False + 6-2: pi=False active=True distance=1.3028 + 2-9: pi=False active=False + 2-8: pi=False active=False diff --git a/autodE/source/autode/transition_states/locate_tss.py b/autodE/source/autode/transition_states/locate_tss.py new file mode 100644 index 0000000000000000000000000000000000000000..e1163f83437e413c5a48fc53add6ceae9e15c039 --- /dev/null +++ b/autodE/source/autode/transition_states/locate_tss.py @@ -0,0 +1,368 @@ +import os +import numpy as np +from scipy.optimize import minimize +from autode.exceptions import NoMapping +from autode.species import Complex +from autode.transition_states import TransitionState, TransitionStates +from autode.transition_states.truncation import get_truncated_species +from autode.transition_states.truncation import is_worth_truncating +from autode.transition_states.ts_guess import get_template_ts_guess +from autode.bond_rearrangement import get_bond_rearrangs +from autode.config import Config +from autode.log import logger +from autode.values import Distance, PotentialEnergy +from autode.methods import get_hmethod +from autode.methods import get_lmethod +from autode.utils import work_in +from autode.mol_graphs import get_mapping +from autode.mol_graphs import reac_graph_to_prod_graph +from autode.bonds import FormingBond, BreakingBond +from autode.path.adaptive import get_ts_adaptive_path +from autode.mol_graphs import species_are_isomorphic +from autode.substitution import get_cost_rotate_translate +from autode.substitution import get_substc_and_add_dummy_atoms + + +def find_tss(reaction): + """ + Find all the possible the transition states of a reaction over possible + paths from reaction.reactant to reaction.product. Will not search the + conformational space of a reaction + + --------------------------------------------------------------------------- + Arguments: + (list(autode.reaction.Reaction)): Reaction + + Returns: + (autode.transition_states.transition_states.TransitionStates): + """ + logger.info("Finding possible transition states") + reactant, product = reaction.reactant, reaction.product + + if species_are_isomorphic(reactant, product): + raise ValueError( + "Reactant and product complexes are isomorphic. " + "Cannot find a TS" + ) + + bond_rearrs = get_bond_rearrangs(reactant, product, name=str(reaction)) + + if bond_rearrs is None: + logger.error("Could not find a set of forming/breaking bonds") + return None + + tss = TransitionStates() + for bond_rearrangement in bond_rearrs: + logger.info( + f"Locating transition state using active bonds " + f"{bond_rearrangement.all}" + ) + + ts = get_ts(str(reaction), reactant, product, bond_rearrangement) + + if ts is not None: + tss.append(ts) + + logger.info( + f"Found *{len(tss)}* transition state(s) that lead to products" + ) + return tss + + +def ts_guess_funcs_prms(name, reactant, product, bond_rearr): + """ + Get the functions and parameters required for the function + + --------------------------------------------------------------------------- + Arguments: + name (str): Unique identifier for this reaction + + reactant (autode.species.Species): + + product (autode.species.Species): + + bond_rearr (autode.bond_rearrangement.BondRearrangement): + + Yields: + (tuple(func, args)): + """ + r, p = reactant.copy(), product.copy() # Reactants/products may be edited + + lmethod, hmethod = get_lmethod(), get_hmethod() + + # TODO: make this less awful (consistent types) + for i, pair in enumerate(bond_rearr.bbonds): + bond_rearr.bbonds[i] = BreakingBond(pair, r, p) + + for i, pair in enumerate(bond_rearr.fbonds): + bond_rearr.fbonds[i] = FormingBond(pair, r, p) + # TODO: ------------------------------------------- + + # Ideally use a transition state template, then only a single constrained + # optimisation needs to be run + yield get_template_ts_guess, ( + r, + p, + bond_rearr, + f"{name}_template_{bond_rearr}", + hmethod, + ) + + if (not r.atoms.contain_metals) and hmethod != lmethod: + yield get_ts_adaptive_path, ( + r, + p, + lmethod, + bond_rearr, + f"{name}_ll_ad_{bond_rearr}", + ) + + yield get_ts_adaptive_path, ( + r, + p, + hmethod, + bond_rearr, + f"{name}_hl_ad_{bond_rearr}", + ) + + yield _get_ts_neb_from_adaptive_path, ( + r, + p, + hmethod, + bond_rearr, + f"{name}_hl_ad_neb_{bond_rearr}", + f"{name}_hl_ad_{bond_rearr}", + ) + return None + + +def translate_rotate_reactant( + reactant, bond_rearrangement, shift_factor, n_iters=10 +): + """ + Shift a molecule in the reactant complex so that the attacking atoms + (a_atoms) are pointing towards the attacked atoms (l_atoms). Applied in + place + + --------------------------------------------------------------------------- + Arguments: + reactant (autode.complex.Complex): + + bond_rearrangement (autode.bond_rearrangement.BondRearrangement): + + shift_factor (float): + + n_iters (int): Number of iterations of translation/rotation to perform + to (hopefully) find the global minima + """ + + if not isinstance(reactant, Complex): + logger.warning("Cannot rotate/translate component, not a Complex") + return + + if reactant.n_molecules < 2: + logger.info( + "Reactant molecule does not need to be translated or " "rotated" + ) + return + + logger.info("Rotating/translating into a reactive conformation... running") + + # This function can add dummy atoms for e.g. SN2' reactions where there + # is not a A -- C -- Xattern for the substitution centre + subst_centres = get_substc_and_add_dummy_atoms( + reactant, bond_rearrangement, shift_factor=shift_factor + ) + + if all( + sc.a_atom in reactant.atom_indexes(mol_index=0) for sc in subst_centres + ): + attacking_mol = 0 + else: + attacking_mol = 1 + + # Disable the logger to prevent rotation/translations printing + logger.disabled = True + + # Find the global minimum for inplace rotation, translation and rotation + min_cost, opt_x = None, None + + for _ in range(n_iters): + res = minimize( + get_cost_rotate_translate, + x0=np.random.random(11), + method="BFGS", + tol=0.1, + args=(reactant, subst_centres, attacking_mol), + ) + + if min_cost is None or res.fun < min_cost: + min_cost = res.fun + opt_x = res.x + + # Re-enable the logger + logger.disabled = False + logger.info(f"Minimum cost for translating/rotating is {min_cost:.3f}") + + # Translate/rotation the attacking molecule optimally + reactant.rotate_mol( + axis=opt_x[:3], theta=opt_x[3], mol_index=attacking_mol + ) + reactant.translate_mol(vec=opt_x[4:7], mol_index=attacking_mol) + reactant.rotate_mol( + axis=opt_x[7:10], theta=opt_x[10], mol_index=attacking_mol + ) + + logger.info(" ... done") + + reactant.atoms.remove_dummy() + reactant.print_xyz_file() + + return None + + +@work_in("truncated") +def get_truncated_ts(name, reactant, product, bond_rearr): + """Get the TS of a truncated reactant and product complex""" + + trnc_reactant = get_truncated_species(reactant, bond_rearr) + trnc_product = get_truncated_species(product, bond_rearr) + + # Re-find the bond rearrangements, which should exist + bond_rearrangs = get_bond_rearrangs(trnc_reactant, trnc_product, name=name) + + if bond_rearrangs is None: + logger.error("Truncation generated a complex with 0 rearrangements") + return None + + # Find all the possible TSs + for bond_rearr in bond_rearrangs: + get_ts( + name, trnc_reactant, trnc_product, bond_rearr, is_truncated=True + ) + + logger.info("Done with truncation") + return + + +def get_ts(name, reactant, product, bond_rearr, is_truncated=False): + """For a bond rearrangement run PES exploration and TS optimisation to + find a TS + + --------------------------------------------------------------------------- + Arguments: + name (str): Unique identifier for this reaction, used for filenames + + reactant (autode.species.ReactantComplex): + + product (autode.species.ProductComplex): + + bond_rearr (autode.bond_rearrangement.BondRearrangement): + + is_truncated (bool, optional): If the reactant is already truncated + then truncation shouldn't be attempted + and there should be no need to shift + Returns: + (autode.transition_states.transition_state.TransitionState): TS + """ + + if bond_rearr.n_fbonds > bond_rearr.n_bbonds: + raise NotImplementedError( + "Cannot treat more forming than breaking " + "bonds, reverse the reaction(?)" + ) + + # If the reaction is a substitution or elimination then the reactants must + # be orientated correctly, no need to re-rotate/translate if truncated + if not is_truncated: + translate_rotate_reactant( + reactant, + bond_rearrangement=bond_rearr, + shift_factor=1.5 if reactant.charge == 0 else 2.5, + ) + + # Reorder the atoms in the product complex so they are equivalent to the + # reactant + try: + mapping = get_mapping( + graph1=product.graph, + graph2=reac_graph_to_prod_graph(reactant.graph, bond_rearr), + ) + product.reorder_atoms(mapping=mapping) + except NoMapping: + logger.warning("Could not find the expected bijection R -> P") + return None + + # If specified then strip non-core atoms from the structure + if not is_truncated and is_worth_truncating(reactant, bond_rearr): + get_truncated_ts(name, reactant, product, bond_rearr) + + # There are multiple methods of finding a transition state. Iterate through + # from the cheapest -> most expensive + for func, params in ts_guess_funcs_prms( + name, reactant, product, bond_rearr + ): + logger.info(f"Trying to find a TS guess with {func.__name__}") + ts_guess = func(*params) + + if ts_guess is None: + continue + + if not ts_guess.could_have_correct_imag_mode: + continue + + # Form a transition state object and run an OptTS calculation + ts = TransitionState(ts_guess, bond_rearr=bond_rearr) + ts.optimise() + + if not ts.is_true_ts: + continue + + # Save a transition state template if specified in the config + if Config.make_ts_template: + ts.save_ts_template(folder_path=Config.ts_template_folder_path) + + logger.info(f"Found a transition state with {func.__name__}") + return ts + + return None + + +def _get_ts_neb_from_adaptive_path( + reactant, product, method, bond_rearr, name, ad_name +): + from autode.neb import NEB + from autode.transition_states.ts_guess import TSguess + + if not os.path.exists(f"{ad_name}_path.xyz"): + logger.warning("Found no adaptive path to generate the NEB from") + return None + + neb = NEB.from_file(f"{ad_name}_path.xyz") + + if not neb.images.contains_peak: + logger.info("Adaptive path had no peak – not running a NEB") + return None + + neb.partition( + max_delta=Distance(0.2, units="Å"), + distance_idxs=bond_rearr.active_atoms, + ) + neb.calculate( + method=method, + n_cores=Config.n_cores, + name_prefix=f"{name}_", + etol_per_image=PotentialEnergy(0.1, units="kcal mol^-1"), + ) + + if neb.images.contains_peak: + ts_guess = TSguess( + atoms=neb.peak_species.atoms, + reactant=reactant, + product=product, + bond_rearr=bond_rearr, + name=name, + ) + return ts_guess + + return None diff --git a/autodE/source/autode/transition_states/templates.py b/autodE/source/autode/transition_states/templates.py new file mode 100644 index 0000000000000000000000000000000000000000..5517bd24f1f5b247d2b4ac5ac550e46b0f095ed5 --- /dev/null +++ b/autodE/source/autode/transition_states/templates.py @@ -0,0 +1,484 @@ +import os +import autode +from datetime import date +from autode.mol_graphs import MolecularGraph +from autode.config import Config +from autode.log import logger +from autode.mol_graphs import is_isomorphic +from autode.exceptions import TemplateLoadingFailed +from autode.solvent.solvents import get_solvent + +""" +The idea with templating is to avoid needless PES scans when finding TSs for +which similar have already been found. + +For instance the TS for the addition of CN- to acetone is going to be perturbed +only slightly by modifying a methyl for a CH2CH3 group. It is more efficient +to know the forming C–C bond distance in the previous TS, fix it and run +a constrained optimisation which will hopefully be a good guess of the TS +""" + + +def get_ts_template_folder_path(folder_path): + """ + Get the full path to the directory containing the transition state + templates, if it's unset then use the default folder in Config if it is + set, or the autode/transition_states/lib folder where autodE is installed + + --------------------------------------------------------------------------- + Arguments: + folder_path: (str or None) + + Returns: + (str): Path to the folder containing TS templates + """ + + if folder_path is not None: + return folder_path + + logger.info("Folder path is not set – TS templates in the default path") + + if Config.ts_template_folder_path == "": + raise ValueError( + "Cannot set ts_template_folder_path to an empty string" + ) + + if Config.ts_template_folder_path is not None: + logger.info("Configuration ts_template_folder_path is set") + return Config.ts_template_folder_path + + else: + ts_dir_path = os.path.dirname(os.path.abspath(__file__)) + return os.path.join(ts_dir_path, "lib") + + +def get_ts_templates(folder_path=None): + """Get all the transition state templates from a folder, or the default if + folder path is None. Transition state templates should be .txt files with + at least a charge, multiplicity, solvent, and a graph with some active + edge including distances. + + --------------------------------------------------------------------------- + Keyword Arguments: + folder_path (str): e.g. '/path/to/the/ts/template/library' + + Returns: + (list(autode.transition_states.templates.TStemplate)): List of + templates + """ + folder_path = get_ts_template_folder_path(folder_path) + logger.info(f"Getting TS templates from {folder_path}") + + if not os.path.exists(folder_path): + logger.error("Folder does not exist") + return [] + + templates = [] + + # Attempt to form transition state templates for all the .txt files in the + # TS template folder + for filename in os.listdir(folder_path): + if not filename.endswith(".txt"): + continue + + try: + template = TStemplate(filename=os.path.join(folder_path, filename)) + templates.append(template) + + except TemplateLoadingFailed: + logger.warning(f"Failed to load a template for {filename}") + + logger.info(f"Have {len(templates)} TS templates") + return templates + + +def template_matches(reactant, truncated_graph, ts_template): + r""" + Determine if a transition state template matches a truncated graph. The + truncated graph includes all the active bonds in the reaction and the + nearest neighbours to those atoms e.g. for a Diels-Alder reaction:: + + H H + \ / + H-C----C-H where the dotted lines represent active bonds + . . + H . . H + \. . / + H - C C - H + \ / + C C + + where the full reaction is between ethene and butadiene. + --------------------------------------------------------------------------- + Arguments: + reactant (autode.complex.ReactantComplex): + + truncated_graph (nx.Graph): + + ts_template (autode.transition_states.templates.TStemplate): + + Returns: + (bool): Template matches + """ + + if ( + reactant.charge != ts_template.charge + or reactant.mult != ts_template.mult + ): + return False + + if reactant.solvent != ts_template.solvent: + return False + + if is_isomorphic(truncated_graph, ts_template.graph): + logger.info("Found matching TS template") + return True + + return False + + +def get_value_from_file(key, file_lines): + """ + Get the value given a key from a list of file lines i.e. a saved template + + Example:: + + Input: + file_lines= + _________________________ + . + multiplicity: 1 + . + ------------------------ + key='multiplicity' + + Output: + 1 + + --------------------------------------------------------------------------- + Arguments: + key (str): + file_lines (list(str)): + + Returns: + (str): Value + + Raise: + (autode.exceptions.TemplateLoadingFailed): If values not found + """ + + for i, line in enumerate(file_lines): + if not line.startswith(str(key)): + continue + + try: + _, value = line.split() + return value + + except (TypeError, ValueError): + raise TemplateLoadingFailed(f"Incorrectly formatted line {i}") + + raise TemplateLoadingFailed(f"Did not find a {key} template") + + +def get_values_dict_from_file(key, file_lines): + """ + Get the value given a key from a list of file lines i.e. a saved template. + Example:: + + Input: + file_lines= + _________________________ + . + . + multiplicity: 1 + nodes: + 0: atom_label=F + 2: atom_label=C + . + . + ------------------------ + key='nodes' + + Output: + {0: {'atom_label': 'F'}, 2: {'atom_label': 'C'}, ..} + + --------------------------------------------------------------------------- + Arguments: + key (str): + file_lines (list(str)): + + Returns: + (dict): Value + + Raise: + (autode.exceptions.TemplateLoadingFailed): If values not found + """ + key_lines = [line for line in file_lines if line.startswith(key)] + + if len(key_lines) != 1: + raise TemplateLoadingFailed(f"Incorrect format of {key} section") + + values_dict = {} + + # Enumerate all indented lines starting after this key + line_idx = file_lines.index(key_lines[0]) + + for i, line in enumerate(file_lines[line_idx + 1 :]): + # Only consider indented lines + if not line.startswith(" "): + break + + # Split the line on spaces + items = line.split() + + if not items[0].endswith(":"): + raise TemplateLoadingFailed(f"Key error on line {i}") + + # This key in the value dictionary is the first item in the line with + # the whitespace and final colon removed + v_key = items[0][:-1] + + # If the key is e.g. 0-1 as an edge then split it to the tuple (0, 1) + if "-" in v_key: + v_key = tuple(int(idx) for idx in v_key.split("-")) + + else: + v_key = int(v_key) + + p_dict = {} + + # Expecting the remaining items to be separated by equals symbols + # e.g. active=True + for item in items[1:]: + p_key, p_value = item.split("=") + + if p_value.lower() == "true": + p_value = True + + elif p_value.lower() == "false": + p_value = False + + else: + try: + p_value = float(p_value) + + except ValueError: + pass + + p_dict[p_key] = p_value + + values_dict[v_key] = p_dict + + logger.info(f"Found {key}: {list(values_dict.keys())}") + return values_dict + + +class TStemplate: + def __init__( + self, + graph=None, + charge=None, + mult=None, + solvent=None, + species=None, + filename=None, + ): + """ + TS template + + ----------------------------------------------------------------------- + Keyword Arguments: + graph (nx.Graph): Active bonds in the TS are represented by the + edges with attribute active=True, going out to nearest bonded + neighbours + + solvent (autode.solvent.solvents.Solvent): + + charge (int): + + mult (int): + + species (autode.species.Species): + + filename (str): Saved template to load + """ + + self._filename = filename + self.graph = graph + self.solvent = solvent + self.charge = charge + self.mult = mult + + if species is not None: + self.solvent = species.solvent + self.charge = species.charge + self.mult = species.mult + + if self._filename is not None: + self.load(filename) + + def _save_to_file(self, file): + """Save this template to a plain text .txt file with a ~yaml syntax""" + + title_line = ( + f"TS template generated by autode v.{autode.__version__}" + f" on {date.today()}\n" + ) + + # Add nodes as a list, and their atom labels/symbols + nodes_str = "" + for i, data in self.graph.nodes(data=True): + nodes_str += f' {i}: atom_label={data["atom_label"]}\n' + + # Add edges as a list and their associated properties as a dict + edges_str = "" + for i, j, data in self.graph.edges(data=True): + edge_str = f" {i}-{j}: " + + if "pi" in data.keys(): + edge_str += f'pi={str(data["pi"])} ' + + if "active" in data.keys(): + edge_str += f'active={str(data["active"])} ' + + if "distance" in data.keys(): + edge_str += f'distance={data["distance"]:.4f} ' + + edges_str += f"{edge_str}\n" + + print( + title_line, + f"solvent: {self.solvent}", + f"charge: {self.charge}", + f"multiplicity: {self.mult}", + "nodes:", + nodes_str, + "edges:", + edges_str, + sep="\n", + file=file, + ) + + return None + + def graph_has_correct_structure(self): + """Check that the graph has some active edges and distances""" + + if self.graph is None: + logger.warning("Incorrect TS template stricture - it was None!") + return False + + n_active_edges = 0 + for edge in self.graph.edges: + if "active" not in self.graph.edges[edge].keys(): + continue + + if not self.graph.edges[edge]["active"]: + continue + + if ( + self.graph.edges[edge]["active"] + and "distance" not in self.graph.edges[edge].keys() + ): + logger.warning("Active edge has no distance") + return False + + n_active_edges += 1 + + # A reasonably structured graph has at least 1 active edge + if n_active_edges >= 1: + return True + + else: + logger.warning("Graph had no active edges") + return False + + def save(self, basename="template", folder_path=None): + """ + Save the TS template object in a plain text .txt file. With folder_path + =None then the template will be saved to the default directory + (see get_ts_template_folder_path). The name of the file will be + basename.txt where i is an integer iterated until the file doesn't + already exist. + + ----------------------------------------------------------------------- + Keyword Arguments: + basename (str): + + folder_path (str or None): + """ + + folder_path = get_ts_template_folder_path(folder_path) + logger.info(f"Saving TS template to {folder_path}") + + if not os.path.exists(folder_path): + logger.info(f"Making directory {folder_path}") + os.mkdir(folder_path) + + # Iterate i until the templatei.obj file doesn't exist + name, i = basename + "0", 0 + while True: + if not os.path.exists(os.path.join(folder_path, f"{name}.txt")): + break + name = basename + str(i) + i += 1 + + file_path = os.path.join(folder_path, f"{name}.txt") + logger.info(f"Saving the template as {file_path}") + + with open(file_path, "w") as template_file: + self._save_to_file(template_file) + + return None + + def load(self, filename): + """ + Load a template from a saved file + + ----------------------------------------------------------------------- + Arguments: + filename (str): + + Raise: + (autode.exceptions.TemplateLoadingFailed): + """ + try: + template_lines = open(filename, "r").readlines() + except (IOError, UnicodeDecodeError): + raise TemplateLoadingFailed("Failed to read file lines") + + if len(template_lines) < 5: + raise TemplateLoadingFailed("Not enough lines in the template") + + name = get_value_from_file("solvent", template_lines) + + if name.lower() == "none": + self.solvent = None + else: + self.solvent = get_solvent(solvent_name=name, kind="implicit") + + self.charge = int(get_value_from_file("charge", template_lines)) + self.mult = int(get_value_from_file("multiplicity", template_lines)) + + # Set the template graph by adding nodes and edges with atoms labels + # and active/pi/distance attributes respectively + self.graph = MolecularGraph() + + nodes = get_values_dict_from_file("nodes", template_lines) + for idx, data in nodes.items(): + self.graph.add_node(idx, **data) + + edges = get_values_dict_from_file("edges", template_lines) + + for pair, data in edges.items(): + self.graph.add_edge(*pair, **data) + + if not self.graph_has_correct_structure(): + raise TemplateLoadingFailed("Incorrect graph structure") + + return None + + @property + def filename(self) -> str: + return "unknown" if self._filename is None else self._filename diff --git a/autodE/source/autode/transition_states/transition_state.py b/autodE/source/autode/transition_states/transition_state.py new file mode 100644 index 0000000000000000000000000000000000000000..4f279b0227e211074a7488a29e8656c0342e58b9 --- /dev/null +++ b/autodE/source/autode/transition_states/transition_state.py @@ -0,0 +1,394 @@ +from typing import Optional, List, TYPE_CHECKING + +from autode.values import Frequency +from autode.transition_states.base import displaced_species_along_mode +from autode.transition_states.base import TSbase +from autode.transition_states.ts_guess import TSguess +from autode.transition_states.templates import TStemplate +from autode.conformers.conformers import Conformers +from autode.input_output import atoms_to_xyz_file +from autode.calculations import Calculation +from autode.config import Config +from autode.exceptions import CalculationException +from autode.geom import calc_heavy_atom_rmsd +from autode.log import logger +from autode.methods import get_hmethod +from autode.mol_graphs import get_truncated_active_mol_graph +from autode.utils import requires_atoms, requires_graph, ProcessPool + + +if TYPE_CHECKING: + from autode.species.species import Species + from autode.wrappers.keywords import Keywords + from autode.wrappers.methods import Method + from autode.bond_rearrangement import BondRearrangement + + +class TransitionState(TSbase): + def __init__( + self, + ts_guess: TSbase, + bond_rearr: Optional["BondRearrangement"] = None, + ): + """ + Transition State + + ----------------------------------------------------------------------- + Arguments: + ts_guess (autode.transition_states.ts_guess.TSguess): + + Keyword Arguments: + bond_rearr (autode.bond_rearrangement.BondRearrangement): + """ + super().__init__( + atoms=ts_guess.atoms, + reactant=ts_guess.reactant, + product=ts_guess.product, + name=f"TS_{ts_guess.name}", + charge=ts_guess.charge, + bond_rearr=ts_guess.bond_rearrangement, + mult=ts_guess.mult, + ) + + self.energy = ts_guess.energy + self.gradient = ts_guess.gradient + self.hessian = ts_guess.hessian + + if bond_rearr is not None: + self.bond_rearrangement = bond_rearr + + self.solvent = ts_guess.solvent + self._update_graph() + + self.warnings = "" #: str for any warnings that may arise + + def __repr__(self): + return self._repr(prefix="TransitionState") + + def __eq__(self, other): + """Equality of this TS to another""" + return super().__eq__(other) + + @requires_graph + def _update_graph(self) -> None: + """Update the molecular graph to include all the bonds that are being + made/broken""" + assert self.graph is not None, "Must have a MolecularGraph" + + if self.bond_rearrangement is None: + logger.warning( + "Bond rearrangement not set - molecular graph " + "updating with no active bonds" + ) + else: + for bond in self.bond_rearrangement.all: + self.graph.add_active_edge(*bond) + + logger.info(f"Molecular graph updated with active bonds") + return None + + def _run_opt_ts_calc(self, method: "Method", name_ext: str) -> None: + """Run an optts calculation and attempt to set the geometry, energy and + normal modes""" + assert method.keywords.opt_ts is not None, "Must have OptTS keywords" + optts_calc = Calculation( + name=f"{self.name}_{name_ext}", + molecule=self, + method=method, + n_cores=Config.n_cores, + keywords=method.keywords.opt_ts, + ) + try: + optts_calc.run() + + if not optts_calc.optimiser.converged: + self._reoptimise(optts_calc, name_ext, method) + + except CalculationException: + logger.error("Transition state optimisation calculation failed") + + return None + + def _reoptimise( + self, calc: Calculation, name_ext: str, method: "Method" + ) -> Calculation: + """Rerun a calculation for more steps""" + + if calc.optimiser.last_energy_change.to("kcal mol-1") > 0.1: + self.warnings += f"TS for {self.name} was not fully converged." + logger.info("Optimisation did not converge") + return calc + + logger.info("Optimisation nearly converged") + if not self.could_have_correct_imag_mode: + logger.warning("Lost imaginary mode") + return calc + + logger.info( + "Still have correct imaginary mode, trying " + "more optimisation steps" + ) + + assert method.keywords.opt_ts is not None, "Must have OptTS keywords" + + calc = Calculation( + name=f"{self.name}_{name_ext}_reopt", + molecule=self, + method=method, + n_cores=Config.n_cores, + keywords=method.keywords.opt_ts, + ) + calc.run() + + return calc + + def _generate_conformers(self, n_confs: Optional[int] = None) -> None: + """Generate conformers at the TS""" + from autode.conformers.conf_gen import get_simanl_conformer + + n_confs = Config.num_conformers if n_confs is None else n_confs + distance_consts = self.active_bond_constraints + self.conformers.clear() + + with ProcessPool(max_workers=Config.n_cores) as pool: + results = [ + pool.submit(get_simanl_conformer, self, distance_consts, i) + for i in range(n_confs) + ] + + self.conformers = [res.result() for res in results] # type: ignore + + self.conformers.prune(e_tol=1e-6) + return None + + @property + def vib_frequencies(self) -> Optional[List[Frequency]]: + """ + Vibrational frequencies, which are all but the lowest 7 as the 6th + is the 'translational' mode over the TS + + ----------------------------------------------------------------------- + Returns: + (list(autode.value.Frequency) | None): + """ + n = 7 if not self.is_linear() else 6 + return self.frequencies[n:] if self.frequencies is not None else None + + @requires_atoms + def print_imag_vector( + self, mode_number: int = 6, name: Optional[str] = None + ) -> None: + """Print a .xyz file with multiple structures visualising the largest + magnitude imaginary mode + + ----------------------------------------------------------------------- + Keyword Arguments: + mode_number (int): Number of the normal mode to visualise, + 6 (default) is the lowest frequency vibration + i.e. largest magnitude imaginary, if present + name (str): + """ + name = self.name if name is None else name + + disp = -0.5 + for i in range(40): + disp_ts = displaced_species_along_mode( + self, mode_number=int(mode_number), disp_factor=disp + ) + atoms_to_xyz_file( + atoms=disp_ts.atoms, filename=f"{name}.xyz", append=True + ) + + # Add displacement so the final set of atoms are +0.5 Å displaced + # along the mode, then displaced back again + sign = 1 if i < 20 else -1 + disp += sign * 1.0 / 20.0 + + return None + + @requires_atoms + def optimise( + self, + name_ext: str = "optts", + method: Optional["Method"] = None, + reset_graph: bool = False, + calc: Optional[Calculation] = None, + keywords: Optional["Keywords"] = None, + ): + """Optimise this TS to a true TS""" + logger.info(f"Optimising {self.name} to a transition state") + + self._run_opt_ts_calc(method=get_hmethod(), name_ext=name_ext) + + # A transition state is a first order saddle point i.e. has a single + # imaginary frequency + if not self.has_imaginary_frequencies: + logger.error( + "Transition state optimisation did not return any " + "imaginary frequencies" + ) + return + + assert self.imaginary_frequencies is not None + if len(self.imaginary_frequencies) == 1: + logger.info("Found a TS with a single imaginary frequency") + return + + if all([freq > -50 for freq in self.imaginary_frequencies[1:]]): + logger.warning( + "Had small imaginary modes - not displacing along " + "other modes" + ) + return + + # There is more than one imaginary frequency. Will assume that the most + # negative is the correct mode.. + for disp_magnitude, ext in zip([1, -1], ["_dis", "_dis2"]): + logger.info( + "Displacing along second imaginary mode to try and " "remove" + ) + + disp_ts: TransitionState = self.copy() + disp_ts.atoms = displaced_species_along_mode( + self, mode_number=7, disp_factor=disp_magnitude + ).atoms + + disp_ts._run_opt_ts_calc( + method=get_hmethod(), name_ext=name_ext + ext + ) + + if ( + self.imaginary_frequencies is not None + and len(self.imaginary_frequencies) == 1 + ): + logger.info( + "Displacement along second imaginary mode " + "successful. Now have 1 imaginary mode" + ) + + # Set the new properties of this TS from a successful reopt + self.atoms = disp_ts.atoms + self.energy = disp_ts.energy + self._hess = disp_ts.hessian + break + + return None + + def find_lowest_energy_ts_conformer( + self, rmsd_threshold: Optional[float] = None + ): + """Find the lowest energy transition state conformer by performing + constrained optimisations""" + logger.info("Finding lowest energy TS conformer") + assert self.energy is not None, "Must have a TS energy" + + # Generate a copy of this TS on which conformers are searched, for + # easy reversion + _ts: TransitionState = self.copy() + _ts.hessian, _ts.gradient, _ts.energy = None, None, None + + hmethod = get_hmethod() if Config.hmethod_conformers else None + _ts.find_lowest_energy_conformer(hmethod=hmethod) + + # Remove similar TS conformer that are similar to this TS based on root + # mean squared differences in their structures being above a threshold + rmsd_threshold = ( + Config.rmsd_threshold if rmsd_threshold is None else rmsd_threshold + ) + _ts.conformers = Conformers( + [ + conf + for conf in _ts.conformers + if calc_heavy_atom_rmsd(conf.atoms, self.atoms) + > rmsd_threshold + ] + ) + + logger.info( + f"Generated {len(_ts.conformers)} unique (RMSD > " + f"{rmsd_threshold} Å) TS conformer(s)" + ) + + if len(_ts.conformers) == 0: + logger.info("Had no conformers - no need to re-optimise") + return + + # Optimise the lowest energy conformer to a transition state - will + # .find_lowest_energy_conformer will have updated self.atoms etc. + _ts.optimise(name_ext="optts_conf") + + if _ts.is_true_ts and _ts.energy < self.energy: + logger.info("Conformer search successful - setting new attributes") + + self.atoms = _ts.atoms + self.energies = _ts.energies + self._hess = _ts.hessian + return None + + de = "nan" if _ts.energy is None else f"{_ts.energy - self.energy:.4f}" # type: ignore + logger.warning( + f"Transition state conformer search failed " + f"(∆E = {de} Ha). Reverting" + ) + return None + + @property + def is_true_ts(self) -> bool: + """Is this TS a 'true' TS i.e. has at least on imaginary mode in the + hessian and is the correct mode""" + + if self.energy is None: + logger.warning("Cannot be true TS with no energy") + return False + + if self.has_imaginary_frequencies and self.has_correct_imag_mode: + logger.info( + "Found a transition state with the correct " + "imaginary mode & links reactants and products" + ) + return True + + return False + + def save_ts_template(self, folder_path: Optional[str] = None) -> None: + """Save a transition state template containing the active bond lengths, + solvent and charge in folder_path + + ----------------------------------------------------------------------- + Keyword Arguments: + folder_path (str): folder to save the TS template to + + (default: {None}) + """ + if self.bond_rearrangement is None: + raise ValueError( + "Cannot save a TS template without a bond " "rearrangement" + ) + + logger.info(f"Saving TS template for {self.name}") + + truncated_graph = get_truncated_active_mol_graph(self.graph) + + for bond in self.bond_rearrangement.all: + truncated_graph.edges[bond]["distance"] = self.distance(*bond) + + ts_template = TStemplate(truncated_graph, species=self) + ts_template.save(folder_path=folder_path) + + logger.info("Saved TS template") + return None + + @classmethod + def from_species(cls, species: "Species") -> "TransitionState": + """ + Generate a TS from a species. Note this does not set the bond rearrangement + thus mode checking will not work from this species. + + ----------------------------------------------------------------------- + Arguments: + species: + + Returns: + (autode.transition_states.transition_state.TransitionState): TS + """ + return cls(ts_guess=TSguess.from_species(species), bond_rearr=None) diff --git a/autodE/source/autode/transition_states/transition_states.py b/autodE/source/autode/transition_states/transition_states.py new file mode 100644 index 0000000000000000000000000000000000000000..6dd7f6e5b033957c63c3342e68bd825b55375b4c --- /dev/null +++ b/autodE/source/autode/transition_states/transition_states.py @@ -0,0 +1,27 @@ +import numpy as np +from autode.log import logger +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from autode.transition_states.transition_state import TransitionState + + +class TransitionStates(list): + @property + def lowest_energy( + self, + ) -> Optional["TransitionState"]: + """ + Return the lowest energy transition state from this set + + ----------------------------------------------------------------------- + Returns: + (autode.transition_states.TransitionState | None): TS, or None if + there are none. + """ + if len(self) == 0: + logger.error("Have no transition states so no lowest energy TS") + return None + + min_idx = np.argmin([ts.energy for ts in self]) + return self[min_idx] diff --git a/autodE/source/autode/transition_states/truncation.py b/autodE/source/autode/transition_states/truncation.py new file mode 100644 index 0000000000000000000000000000000000000000..90809c850672934514dbca3ed629b779befe8b52 --- /dev/null +++ b/autodE/source/autode/transition_states/truncation.py @@ -0,0 +1,369 @@ +from copy import deepcopy +import networkx as nx +from autode.config import Config +from autode.atoms import Atom +from autode.transition_states.ts_guess import has_matching_ts_templates +from autode.mol_graphs import MolecularGraph +from autode.log import logger + + +def add_core_pi_bonds(molecule, s_molecule, truncated_graph): + """ + Add π bonds that are nearest neighbours to the current atoms in the + truncated graph + + --------------------------------------------------------------------------- + Arguments: + molecule (autode.species.Species): + + s_molecule (autode.species.Species): Stripped molecule + + truncated_graph (nx.Graph): + """ + logger.info("Adding π bonds to the truncated graph") + + curr_nodes = deepcopy(truncated_graph.nodes) + + while True: + for bond in s_molecule.graph.edges: + if ( + s_molecule.graph.edges[bond]["pi"] is False + or bond in truncated_graph.edges + ): + continue + + # At least one of the atoms in the bond needs to be in the + # current structure + if all(atom_index not in curr_nodes for atom_index in bond): + continue + + nodes = [ + (i, molecule.graph.nodes[i]) + for i in bond + if i not in truncated_graph.nodes + ] + truncated_graph.add_nodes_from(nodes) + + truncated_graph.add_edges_from( + [(*bond, molecule.graph.edges[bond])] + ) + + if truncated_graph.number_of_nodes() == len(curr_nodes): + break + + else: + curr_nodes = deepcopy(truncated_graph.nodes) + + return curr_nodes + + +def add_capping_atom(atom_index, n_atom_index, graph, s_molecule): + r""" + Add a capping atom. Example:: + + H + / + C_a---C_b - H -> C_a--H where C_a is numbered atom_index, + \ C_b is numbered n_atom_index + \ + H + + --------------------------------------------------------------------------- + Arguments: + atom_index (int): + + n_atom_index (int): + + graph (nx.Graph): Current molecular graph of the stripped/truncated + molecule + + s_molecule (autode.species.Species): Stripped molecule + """ + logger.info( + f"Swapping saturated carbon {n_atom_index} next to atom " + f"{atom_index} for hydrogen" + ) + + graph.add_node(n_atom_index, atom_label="H", stereo=False) + + # Relabel the atom in the stripped molecule + s_molecule.atoms[n_atom_index].label = "H" + + # Shift the added capping hydrogen to the 'ideal' E-H bond length + curr_dist = s_molecule.distance(atom_index, n_atom_index) + ideal_dist = ( + s_molecule.atoms[atom_index].covalent_radius + + Atom("H").covalent_radius + ) + shift_vec = ( + s_molecule.atoms[n_atom_index].coord + - s_molecule.atoms[atom_index].coord + ) + shift_vec *= (ideal_dist - curr_dist) / curr_dist + + s_molecule.atoms[n_atom_index].translate(vec=shift_vec) + + return None + + +def add_capping_atoms(molecule, s_molecule, truncated_graph, curr_nodes): + """ + Add capping atoms to the graph, truncating over C-C single bonds where + appropriate + + --------------------------------------------------------------------------- + Arguments: + molecule (autode.species.Species): + + s_molecule (autode.species.Species): Stripped molecule + + truncated_graph (nx.Graph): + + curr_nodes (list(int)): + """ + # Set of atom indexes (R) that have been replaced for H + truncated_nodes = [] + + while True: + for i in curr_nodes: + if i in truncated_nodes: + # Truncated atoms by definition do not have any neighbours + # that are not already in the graph + continue + + for n_atom_index in s_molecule.graph.neighbors(i): + if ( + n_atom_index in curr_nodes + or n_atom_index in truncated_nodes + ): + continue + + n_neighbours = len( + list(s_molecule.graph.neighbors(n_atom_index)) + ) + + # Three conditions that must be met for the n_atom_index -> H + if ( + s_molecule.atoms[n_atom_index].label == "C" + and n_neighbours == 4 + ): + truncated_nodes.append(n_atom_index) + + add_capping_atom( + i, + n_atom_index, + graph=truncated_graph, + s_molecule=s_molecule, + ) + + else: + truncated_graph.add_nodes_from( + [(n_atom_index, molecule.graph.nodes[n_atom_index])] + ) + + truncated_graph.add_edges_from( + [ + ( + i, + n_atom_index, + molecule.graph.edges[(i, n_atom_index)], + ) + ] + ) + + if truncated_graph.number_of_nodes() == len(curr_nodes): + # No nodes have been added on this iteration + break + + else: + curr_nodes = deepcopy(truncated_graph.nodes) + + return None + + +def add_remaining_bonds(truncated_graph, full_graph): + """Truncation by adding atoms and their nearest neighbours may miss bonds + between sections that aren't connected initially, so add them""" + + for i, j in full_graph.edges: + if i not in truncated_graph.nodes: + continue + # At least j in the graph + + if j not in truncated_graph.nodes: + continue + + # i and j are in the graph + if (i, j) in truncated_graph.edges: + continue + + # Don't alter bonding if the atom has changed e.g. C -> H + if any( + truncated_graph.nodes[k]["atom_label"] + != full_graph.nodes[k]["atom_label"] + for k in (i, j) + ): + continue + + # an edge doesn't exist between atoms i ang j - make it + truncated_graph.add_edge(i, j, pi=False, active=False) + + return None + + +def add_remaining_atoms(truncated_graph, full_graph, s_molecule): + """Truncation can lead to a split across a C-C bond in a ring where one + of the carbons is no longer has 4 nearest neighbours""" + + for i in deepcopy(truncated_graph.nodes): + # No modification needed if the valency of this atom is retained + n_truncated_neighbours = len(list(truncated_graph.neighbors(i))) + n_full_neighbours = len(list(full_graph.neighbors(i))) + + if n_truncated_neighbours == n_full_neighbours: + continue + + # Only consider non-swapped atoms e.g. not where C -> H + if ( + truncated_graph.nodes[i]["atom_label"] + != full_graph.nodes[i]["atom_label"] + ): + continue + + logger.warning(f"Atom {i} changed valency in truncation") + for n in nx.neighbors(full_graph, i): + if (i, n) in truncated_graph.edges: + continue + + # Missing atom n from the truncated graph - probably truncated + # X -> H but was also bonded to another atom also in the truncated + # graph. + x, y, z = s_molecule.atoms[n].coord + s_molecule.atoms.append(Atom(atomic_symbol="Og", x=x, y=y, z=z)) + + # Add the capping H atom in place of the X atom just added + # will be the last atom index, if it's just been added + add_capping_atom( + atom_index=i, + n_atom_index=len(s_molecule.atoms) - 1, + graph=truncated_graph, + s_molecule=s_molecule, + ) + + # Also add the edge between the added atom and the one that changed + # valency + truncated_graph.add_edge( + i, len(s_molecule.atoms) - 1, pi=False, active=False + ) + + logger.info( + f"New valency is {len(list(truncated_graph.neighbors(i)))}" + ) + + return None + + +def get_truncated_species(species, bond_rearrangement): + """ + From a truncated species by removing non core atoms and adding + capping atoms where appropriate + + --------------------------------------------------------------------------- + Arguments: + species (autode.species.Species): + + bond_rearrangement (autode.bond_rearrangement.BondRearrangement): + + Returns: + (autode.complex.ReactantComplex) + """ + + active_atoms = bond_rearrangement.active_atoms + t_species = species.new_species(name=f"{species.name}_truncated") + + logger.info( + f"Truncating {species.name} with {species.n_atoms} atoms " + f"around core atoms: {active_atoms}" + ) + t_graph = MolecularGraph() + + # Add all the core active atoms to the graphs, their nearest neighbours + # and the bonds between them + t_graph.add_nodes_from([(i, species.graph.nodes[i]) for i in active_atoms]) + + for i in active_atoms: + t_graph.add_nodes_from( + [(j, species.graph.nodes[j]) for j in species.graph.neighbors(i)] + ) + t_graph.add_edges_from( + [ + (i, j, species.graph.edges[(i, j)]) + for j in species.graph.neighbors(i) + ] + ) + + # Add all the π bonds that are associated with the core atoms, then close + # those those etc. + curr_nodes = add_core_pi_bonds(species, t_species, truncated_graph=t_graph) + + # Swap all saturated carbons and the attached fragment for H + logger.warning("Truncation is only implemented over C-X single bonds") + add_capping_atoms( + species, t_species, truncated_graph=t_graph, curr_nodes=curr_nodes + ) + + add_remaining_bonds(t_graph, full_graph=species.graph) + add_remaining_atoms( + t_graph, full_graph=species.graph, s_molecule=t_species + ) + + # Delete all atoms not in the truncated graph and reset the graph + t_species.graph = t_graph + t_species.atoms = [ + atom + for i, atom in enumerate(t_species.atoms) + if i in sorted(t_graph.nodes) + ] + + # Relabel the nodes so they correspond to the new set of atoms + mapping = { + node_label: i for i, node_label in enumerate(sorted(t_graph.nodes)) + } + + t_species.graph = nx.relabel_nodes(t_species.graph, mapping=mapping) + + logger.info(f"Truncated to {t_species.n_atoms} atoms") + return t_species + + +def is_worth_truncating(reactant_complex, bond_rearrangement): + """ + Evaluate whether it is worth truncating a complex + + --------------------------------------------------------------------------- + Arguments: + reactant_complex (autode.complex.ReactantComplex): + + bond_rearrangement (autode.bond_rearrangement.BondRearrangement): + """ + if has_matching_ts_templates(reactant_complex, bond_rearrangement): + logger.info( + "Not truncating a reactant (complex) that has a saved " "template" + ) + return False + + truncated_complex = get_truncated_species( + reactant_complex, bond_rearrangement + ) + + n_removed_atoms = reactant_complex.n_atoms - truncated_complex.n_atoms + + if n_removed_atoms < Config.min_num_atom_removed_in_truncation: + logger.info( + f"Truncated complex only had {n_removed_atoms} atoms " + f"fewer than the full complex. Not truncating" + ) + return False + + logger.info("Complex is worth truncating") + return True diff --git a/autodE/source/autode/transition_states/ts_guess.py b/autodE/source/autode/transition_states/ts_guess.py new file mode 100644 index 0000000000000000000000000000000000000000..9a78667fab43d6c694dd0770cd4ece4c5e260dea --- /dev/null +++ b/autodE/source/autode/transition_states/ts_guess.py @@ -0,0 +1,288 @@ +from typing import Optional, TYPE_CHECKING + +from autode.transition_states.base import TSbase +from autode.transition_states.templates import get_ts_templates +from autode.transition_states.templates import template_matches +from autode.input_output import atoms_to_xyz_file +from autode.calculations import Calculation +from autode.constraints import DistanceConstraints +from autode.config import Config +from autode.values import Distance +from autode.exceptions import CalculationException +from autode.log import logger +from autode.utils import work_in +from autode.methods import get_lmethod, get_hmethod +from autode.mol_graphs import ( + get_mapping_ts_template, + get_truncated_active_mol_graph, +) + +if TYPE_CHECKING: + from autode.species import ReactantComplex, ProductComplex, Species + from autode.bond_rearrangement import BondRearrangement + from autode.wrappers.methods import Method + from autode.wrappers.keywords import Keywords + + +def has_matching_ts_templates( + reactant: "ReactantComplex", + bond_rearr: "BondRearrangement", +): + """ + See if there are any templates suitable to get a TS guess from a template + + --------------------------------------------------------------------------- + Arguments: + reactant (autode.complex.ReactantComplex): + + bond_rearr (autode.bond_rearrangement.BondRearrangement): + + Returns: + (bool): + """ + + mol_graph = get_truncated_active_mol_graph( + graph=reactant.graph, active_bonds=bond_rearr.all + ) + ts_guess_templates = get_ts_templates() + + for ts_template in ts_guess_templates: + if template_matches( + reactant=reactant, + ts_template=ts_template, + truncated_graph=mol_graph, + ): + return True + + return False + + +def get_template_ts_guess( + reactant: "ReactantComplex", + product: "ProductComplex", + bond_rearr: "BondRearrangement", + name: str, + method: "Method", +): + """ + Get a transition state guess object by searching though the stored TS + templates + + --------------------------------------------------------------------------- + Arguments: + reactant (autode.complex.ReactantComplex): + + bond_rearr (autode.bond_rearrangement.BondRearrangement): + + product (autode.complex.ProductComplex): + + method (autode.wrappers.base.ElectronicStructureMethod): + + name (str): + + Returns: + (autode.transition_states.ts_guess.TSguess): + """ + logger.info("Getting TS guess from stored TS template") + active_bonds_and_dists_ts = {} + + # This will add edges so don't modify in place + mol_graph = get_truncated_active_mol_graph( + graph=reactant.graph, active_bonds=bond_rearr.all + ) + + for ts_template in get_ts_templates(): + if not template_matches( + reactant=reactant, + ts_template=ts_template, + truncated_graph=mol_graph, + ): + continue + + # Get the mapping from the matching template + mapping = get_mapping_ts_template( + larger_graph=mol_graph, smaller_graph=ts_template.graph + ) + + for active_bond in bond_rearr.all: + i, j = active_bond + logger.info(f"Mapping active bond {i}-{j}") + + try: + dist = ts_template.graph.edges[mapping[i], mapping[j]][ + "distance" + ] + active_bonds_and_dists_ts[active_bond] = dist + + except KeyError: + logger.warning(f"Couldn't find a mapping for bond {i}-{j}") + + if len(active_bonds_and_dists_ts) != len(bond_rearr.all): + continue + + logger.info( + f"Found a matching template in: {ts_template.filename}. " + f"Creating a TS guess" + ) + ts_guess = TSguess( + name=f"ts_guess_{name}", + atoms=reactant.atoms, + reactant=reactant, + product=product, + bond_rearr=bond_rearr, + ) + + try: + ts_guess.run_constrained_opt( + name=name, + distance_consts=active_bonds_and_dists_ts, + method=method, + keywords=method.keywords.opt, + ) + return ts_guess + + except CalculationException: + logger.warning("Failed to run constrained optimisation on the TS") + continue + + return None + + +class TSguess(TSbase): + """Transition state guess""" + + @classmethod + def from_species(cls, species: "Species") -> "TSguess": + """ + Generate a TS guess from a species + + ----------------------------------------------------------------------- + Arguments: + species: + + Returns: + (autode.transition_states.ts_guess.TSguess): TS guess + """ + + ts_guess = cls( + atoms=species.atoms, + charge=species.charge, + mult=species.mult, + name=f"ts_guess_{species.name}", + solvent_name=None + if species.solvent is None + else species.solvent.name, + ) + + return ts_guess + + @work_in("scan_to_template") + def _lmethod_scan_to_point(self): + """ + Run a set of constrained low-level optimisations from the current + distances to the final set of constraints using a linear path with + small distance increments. + + ----------------------------------------------------------------------- + Raises: + (autode.exceptions.CalculationException): + """ + l_method = get_lmethod() + + final_constraints = self.constraints.distance + current_constraints = { + atom_idx_pair: self.distance(*atom_idx_pair) + for atom_idx_pair in final_constraints.keys() + } + + # Number of steps to use is 0.1 Å in the maximum distance delta + max_delta = max( + abs(final_constraints[bond] - c_dist) + for bond, c_dist in current_constraints.items() + ) + n_steps = int(max_delta / Distance(0.1, units="ang")) + + if n_steps < 2: + logger.info(f"No need to scan - only going to do {n_steps} steps") + return + + for i in range(1, n_steps + 1): + constraints = {} + for atom_idx_pair, c_dist in current_constraints.items(): + delta_dist = final_constraints[atom_idx_pair] - c_dist # ∆r + constraints[atom_idx_pair] = c_dist + i * delta_dist / n_steps + + self.constraints.distance = constraints + + opt = Calculation( + name=f"{self.name}_const_opt_ll_{i}", + molecule=self, + method=l_method, + keywords=l_method.keywords.low_opt, + n_cores=Config.n_cores, + ) + + self.optimise(calc=opt) # Can raise CalculationException + + atoms_to_xyz_file( + self.atoms, filename=f"{self.name}_ll_path.xyz", append=True + ) + return None + + def run_constrained_opt( + self, + name: str, + distance_consts: Optional[dict] = None, + method: Optional["Method"] = None, + keywords: Optional["Keywords"] = None, + ): + """Get a TS guess from a constrained optimisation with the active atoms + fixed at values defined in distance_consts + + ----------------------------------------------------------------------- + Arguments: + name (str): + + keywords (autode.wrappers.keywords.Keywords): + + distance_consts (dict): Distance constraints to use, if None + then use self.constraints + + method (autode.wrappers.base.ElectronicStructureMethod): if + None then use the default method + + keywords (autode.wrappers.keywords.Keywords): If None then use + the default optimisation keywords + + Raises: + (autode.exceptions.CalculationException): + """ + logger.info("Running constrained optimisation on TS guess geometry") + + if distance_consts is not None: + self.constraints.distance = DistanceConstraints(distance_consts) + + self._lmethod_scan_to_point() + + # Default to high-level regular optimisations + if method is None: + method = get_hmethod() + if keywords is None: + keywords = method.keywords.opt + assert ( + keywords is not None + ), "Keywords must be defined to do an opt" + + assert self.constraints.distance, "Must have some distance constraints" + + hl_const_opt = Calculation( + name=f"{name}_constrained_opt", + molecule=self, + method=method, + keywords=keywords, + n_cores=Config.n_cores, + ) + + self.optimise(calc=hl_const_opt) + self.constraints.distance.clear() + return None diff --git a/autodE/source/autode/units.py b/autodE/source/autode/units.py new file mode 100644 index 0000000000000000000000000000000000000000..7945bfab19544fc71d30651bde2d561415fa2e70 --- /dev/null +++ b/autodE/source/autode/units.py @@ -0,0 +1,294 @@ +from typing import Union, Collection, Optional +from autode.constants import Constants + + +class Unit: + def __str__(self): + return f"Unit({self.name})" + + def __repr__(self): + return self.__str__() + + def lower(self): + """Lower case name of the unit""" + return self.name.lower() + + def __eq__(self, other): + """Equality of two units""" + return other.lower() in self.aliases + + def __init__( + self, + name: str, + times: float = 1.0, + add: float = 0.0, + aliases: Optional[Collection] = None, + plot_name: Optional[str] = None, + ): + """ + Unit + + ---------------------------------------------------------------------- + Arguments: + name (str): + + times (float): Conversion from default units to the new + + Keyword Arguments: + aliases (list | set | tuple | None): Set of name aliases for this + unit + + plot_name (str | None): Name to use if this unit is used in a plot + """ + + self.name = name + self.times = times + self.add = add + + self.aliases = [name.lower()] + if aliases is not None: + self.aliases += [alias.lower() for alias in aliases] + + self.plot_name = plot_name if plot_name is not None else name + + +class BaseUnit(Unit): + """A unit in the base unit system, thus an identity conversion factor""" + + def __init__( + self, + name: str, + aliases: Union[Collection, None] = None, + plot_name: Union[str, None] = None, + ): + super().__init__(name, times=1.0, aliases=aliases, plot_name=plot_name) + + +class CompositeUnit(Unit): + def __init__( + self, + *args: Unit, + per: Optional[Collection[Unit]] = None, + name: Union[str, None] = None, + aliases: Union[Collection, None] = None, + ): + """ + A unit as a composite of others, e.g. Ha Å^-1 + + Arguments: + args (autode.units.Unit): Units on the numerator + + per (list(autode.units.Unit) | None): Units on the denominator + """ + per_units: Collection[Unit] = [] if per is None else per + + if name is None: + top_names = " ".join([u.name for u in args]) + per_names = " ".join([u.name for u in per_units]) + name = f"{top_names}({per_names})^-1" + + conversion: float = 1.0 + for unit in args: + conversion *= unit.times + + for unit in per_units: + conversion /= unit.times + + super().__init__(name=name, times=conversion, aliases=aliases) + + +# ----------------------------- Energies ------------------------------- + + +ha = BaseUnit(name="Ha", aliases=["hartree", "Eh"], plot_name="Ha") + + +ev = Unit( + name="eV", + times=Constants.ha_to_eV, + aliases=["electron volt", "electronvolt"], + plot_name="eV", +) + + +# Upper case name to maintain backwards compatibility +kjmol = KjMol = Unit( + name="kJ mol-1", + times=Constants.ha_to_kJmol, + aliases=["kjmol", "kjmol-1", "kj mol^-1", "kj", "kj mol"], + plot_name="kJ mol$^{-1}$", +) + + +kcalmol = KcalMol = Unit( + name="kcal mol-1", + times=Constants.ha_to_kcalmol, + aliases=["kcalmol", "kcalmol-1", "kcal mol^-1", "kcal", "kcal mol"], + plot_name="kcal mol$^{-1}$", +) + +J = Unit(name="J", times=Constants.ha_to_J, aliases=["joule"]) + + +def energy_unit_from_name(name: str) -> "Unit": + """ + Generate an energy unit given a name + + --------------------------------------------------------------------------- + Arguments: + name: Name of the unit + + Raises: + (StopIteration): If a suitable energy unit is not found + """ + + for unit in (ha, ev, kcalmol, kjmol, J): + if name.lower() in unit.aliases: + return unit + + raise StopIteration( + f"Failed to convert {name} to a valid energy unit " + f"must be one of: {ha, ev, kcalmol, kjmol, J}" + ) + + +# ---------------------------------------------------------------------- +# ------------------------------ Angles -------------------------------- + +rad = BaseUnit(name="rad", aliases=["radians", "rads", "radian"]) + + +deg = Unit( + name="°", times=Constants.rad_to_deg, aliases=["deg", "degrees", "º"] +) + +# ---------------------------------------------------------------------- +# ---------------------------- Distances ------------------------------- + +ang = BaseUnit(name="Å", aliases=["ang", "angstrom"]) + + +a0 = Unit(name="bohr", times=Constants.ang_to_a0, aliases=["a0"]) + +nm = Unit( + name="nm", + times=Constants.ang_to_nm, + aliases=["nanometer", "nano meter"], +) + +pm = Unit( + name="pm", + times=Constants.ang_to_pm, + aliases=["picometer", "pico meter"], +) + +m = Unit(name="m", times=Constants.ang_to_m, aliases=["meter"]) + + +ang_amu_half = BaseUnit( + name="Å amu^1/2", aliases=["ang amu^1/2", "Å amu^0.5", "ang amu^0.5"] +) + +# ---------------------------------------------------------------------- +# ------------------------------ Masses -------------------------------- + +amu = BaseUnit(name="amu", aliases=["Da", "g mol-1", "g mol^-1", "g/mol"]) + +kg = Unit(name="kg", times=Constants.amu_to_kg) + +m_e = Unit(name="m_e", times=Constants.amu_to_me, aliases=["me"]) + +# ---------------------------------------------------------------------- +# -------------------- Mass-weighted distance squared ------------------ + +amu_ang_sq = CompositeUnit(amu, ang, ang, name="amu Å^2") + +kg_m_sq = CompositeUnit(kg, m, m, name="kg m^2") + +# ---------------------------------------------------------------------- +# ----------------------------- Gradients ------------------------------ + + +ha_per_ang = CompositeUnit( + ha, per=[ang], aliases=["ha / Å", "ha Å-1", "ha Å^-1", "ha/ang"] +) + +ha_per_a0 = CompositeUnit( + ha, per=[a0], aliases=["ha / a0", "ha a0-1", "ha a0^-1", "ha/bohr"] +) + +ev_per_ang = CompositeUnit( + ev, per=[ang], aliases=["ev / Å", "ev Å^-1", "ev/ang"] +) + +kcalmol_per_ang = CompositeUnit( + kcalmol, + per=[ang], + aliases=["ha kcal mol-1", "ha/kcal mol-1", "kcal mol^-1 Å^-1"], +) + +# ---------------------------------------------------------------------- +# ------------------------- 2nd derivatives ---------------------------- + +ha_per_ang_sq = CompositeUnit( + ha, + per=[ang, ang], + name="Ha Å^-2", + aliases=["Ha / Å^2", "ha/ang^2", "ha/ang2", "ha ang^-2"], +) + +ha_per_a0_sq = CompositeUnit( + ha, + per=[a0, a0], + name="Ha a0^-2", + aliases=[ + "ha/bohr^2", + "ha/bohr2", + "ha bohr^-2", + "ha/a0^2", + "ha/a02", + "ha a0^-2", + ], +) + +J_per_ang_sq = CompositeUnit( + J, per=[ang, ang], name="J ang^-2", aliases=["J/ang^2", "J/ang2", "J ang2"] +) + +J_per_m_sq = CompositeUnit( + J, per=[m, m], name="J m^-2", aliases=["J/m^2", "J/m2", "J m2"] +) + +J_per_ang_sq_kg = CompositeUnit(J, per=[ang, ang, kg], name="J m^-2 kg^-1") + + +# ---------------------------------------------------------------------- +# --------------------------- Frequencies ------------------------------ + +wavenumber = BaseUnit(name="cm^-1", aliases=["cm-1", "per cm", "/cm"]) + +hz = Unit( + name="s^-1", times=Constants.per_cm_to_hz, aliases=["hz", "s-1", "/s"] +) + + +# ---------------------------------------------------------------------- +# ------------------- Digital storage allocation ----------------------- + + +byte = Unit( + name="byte", times=1e6, aliases=["bytes"] +) # 1,000,000 bytes = 1 MB + +MB = BaseUnit(name="mb", aliases=["megabyte"]) + +GB = Unit(name="gb", times=1e-3, aliases=["gigabyte"]) # 1000 MB = 1 GB + +TB = Unit(name="tb", times=1e-6, aliases=["terabyte"]) # 1000 GB = 1 TB + + +# ---------------------------------------------------------------------- +# --------------------------- Temperature ------------------------------ + +kelvin = BaseUnit(name="kelvin", aliases=["K", "°K"]) +celsius = Unit(name="celsius", add=273.15, aliases=["C", "oC", "°C"]) diff --git a/autodE/source/autode/utils.py b/autodE/source/autode/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..a3df97cc200b08c012f290ccb7204e04c4677601 --- /dev/null +++ b/autodE/source/autode/utils.py @@ -0,0 +1,781 @@ +import os +import sys +import platform +import shutil +import copy +import signal +import warnings +import contextlib +from time import time +from typing import Any, Optional, Sequence, List, Callable, TYPE_CHECKING +from functools import wraps +from subprocess import Popen, PIPE, STDOUT +from tempfile import mkdtemp +import multiprocessing + +from autode.config import Config +from autode.log import logger +from autode.values import Allocation +from autode.exceptions import ( + AutodeException, + NoAtomsInMolecule, + NoCalculationOutput, + NoConformers, + NoMolecularGraph, + MethodUnavailable, + CouldNotGetProperty, +) + +if TYPE_CHECKING: + from autode.reactions.reaction import Reaction + from autode.config import _ConfigClass + + +@contextlib.contextmanager +def temporary_config(): + """ + Context manager to temporarily change autodE's Config. When it + exits, the Config will be restored to whatever it was before + calling the context manager. + + Example usage: + + .. code-block:: Python + + >>> import autode as ade + >>> from autode import Config + >>> Config.hcode = 'ORCA' + >>> with ade.temporary_config(): + ... # change some config vars + ... Config.n_cores = 16 + ... Config.ORCA.keywords.sp.functional = 'B3LYP' + ... # then do some calculations + ... # ------ + >>> # When context manager returns, Config should be restored to what it was before + >>> assert Config.n_cores == 4 + >>> assert str(Config.ORCA.keywords.sp.functional).lower() == 'pbe0' + + """ + original_config_data = copy.deepcopy(Config.__dict__) + + try: + yield + finally: + Config.__dict__.update(original_config_data) + + return None + + +def _copy_into_current_config( + parent_config: "_ConfigClass", +) -> None: + """ + Copies an instance of Config into current process. Required to set the + process pool workers' Config to the same state as the parent, when + not forking the interpreter. To be only run on initializing workers + + Args: + parent_config: Parent config instance that will be copied into + present process + """ + Config.__dict__.update(parent_config.__dict__) + + +def get_total_memory() -> int: + """Returns total amount of physical memory available in bytes""" + if sys.platform == "win32" or platform.system() == "Windows": + return _get_total_memory_on_windows() + else: + return os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES") + + +def _get_total_memory_on_windows() -> int: + """Use WinAPI to get total memory on Windows machines""" + from ctypes import Structure, c_int32, c_uint64, sizeof, byref, windll # type: ignore + + # Use Win32 API : https://stackoverflow.com/questions/31546309/ + class MemoryStatusEx(Structure): + _fields_ = [ + ("length", c_int32), + ("memoryLoad", c_int32), + ("totalPhys", c_uint64), + ("availPhys", c_uint64), + ("totalPageFile", c_uint64), + ("availPageFile", c_uint64), + ("totalVirtual", c_uint64), + ("availVirtual", c_uint64), + ("availExtendedVirtual", c_uint64), + ] + + def __init__(self): + super().__init__() + self.length = sizeof(self) + + win_mem = MemoryStatusEx() + if windll.kernel32.GlobalMemoryStatusEx(byref(win_mem)): + return int(win_mem.totalPhys) + else: + raise OSError + + +def check_sufficient_memory(func: Callable): + """Decorator to check that enough memory is available for a calculation""" + + @wraps(func) + def wrapped_function(*args, **kwargs): + physical_mem = None + required_mem = int(Config.n_cores) * Config.max_core + + try: + physical_mem = Allocation(get_total_memory(), units="bytes") + except (ValueError, OSError): + logger.warning("Cannot check physical memory") + + if physical_mem is not None and physical_mem < required_mem: + raise RuntimeError( + "Cannot run function - insufficient memory. Had" + f' {physical_mem.to("GB")} GB but required ' + f'{required_mem.to("GB")} GB' + ) + + return func(*args, **kwargs) + + return wrapped_function + + +@check_sufficient_memory +def run_external( + params: List[str], output_filename: str, stderr_to_log: bool = True +): + """ + Standard method to run a EST calculation with subprocess writing the + output to the calculation output filename + + --------------------------------------------------------------------------- + Arguments: + params: e.g. [/path/to/method, input-filename] + + output_filename: Filename to output stdout to + + stderr_to_log: Should the stderr be added to the logged warnings? + """ + + with open(output_filename, "w") as output_file: + # /path/to/method input_filename > output_filename + process = Popen(params, stdout=output_file, stderr=PIPE) + if process.stderr is not None: + with process.stderr: + for line in iter(process.stderr.readline, b""): + if stderr_to_log: + logger.warning("STDERR: %r", line.decode()) + + process.wait() + + return None + + +@check_sufficient_memory +def run_external_monitored( + params: Sequence[str], + output_filename: str, + break_word: str = "MPI_ABORT", + break_words: Optional[List[str]] = None, +): + """ + Run an external process monitoring the standard output and error for a + word that will terminate the process + + --------------------------------------------------------------------------- + Arguments: + params (list(str)): + + output_filename (str): + + break_word (str): String that if found will terminate the process + + break_words (list(str) | None): List of break_word-s + """ + # Defining a set will override a single break word + break_words = [break_word] if break_words is None else break_words + + def output_reader(process, out_file): + for line in process.stdout: + if any(word in line.decode("utf-8") for word in break_words): + raise ChildProcessError + + print(line.decode("utf-8"), end="", file=out_file) + + return None + + with open(output_filename, "w") as output_file: + proc = Popen(params, stdout=PIPE, stderr=STDOUT) + + try: + output_reader(proc, output_file) + + except ChildProcessError: + logger.warning("External terminated") + proc.terminate() + return None + + return None + + +def work_in(dir_ext: str) -> Callable: + """Execute a function in a different directory""" + + def func_decorator(func): + @wraps(func) + def wrapped_function(*args, **kwargs): + here = os.getcwd() + dir_path = os.path.join(here, dir_ext) + + if not os.path.isdir(dir_path): + logger.info(f"Creating directory to store files: {dir_path:}") + os.mkdir(dir_path) + + os.chdir(dir_path) + try: + result = func(*args, **kwargs) + finally: + os.chdir(here) + + if len(os.listdir(dir_path)) == 0: + logger.warning( + f"Worked in {dir_path} but made no files " + f"- deleting" + ) + cleanup_after_timeout() + os.rmdir(dir_path) + + return result + + return wrapped_function + + return func_decorator + + +def work_in_tmp_dir( + filenames_to_copy: Optional[Sequence[str]] = None, + kept_file_exts: Optional[Sequence[str]] = None, + use_ll_tmp: bool = False, +) -> Callable: + """Execute a function in a temporary directory. + + ----------------------------------------------------------------------- + Arguments: + filenames_to_copy: Filenames to copy to the temp dir + + kept_file_exts: Filename extensions to copy back from the temp dir + + use_ll_tmp (bool): If true then use autode.config.Config.ll_tmp_dir + """ + from autode.config import Config + + if filenames_to_copy is None: + filenames_to_copy = [] + + if kept_file_exts is None: + kept_file_exts = [] + + def func_decorator(func): + @wraps(func) + def wrapped_function(*args, **kwargs): + here = os.getcwd() + + base_dir = Config.ll_tmp_dir if use_ll_tmp else None + + if base_dir is not None: + assert os.path.exists(base_dir) + + tmpdir_path = mkdtemp(dir=base_dir) + logger.info(f"Creating tmpdir to work in: {tmpdir_path}") + + if len(filenames_to_copy) > 0: + logger.info(f"Copying {filenames_to_copy}") + + for filename in filenames_to_copy: + if filename.endswith("_mol.in"): + # MOPAC needs the file to be called this + shutil.move(filename, os.path.join(tmpdir_path, "mol.in")) + else: + shutil.copy(filename, tmpdir_path) + + # Move directories and execute + os.chdir(tmpdir_path) + + try: + logger.info("Function ...running") + result = func(*args, **kwargs) + logger.info(" ...done") + + for filename in os.listdir(tmpdir_path): + if any([filename.endswith(ext) for ext in kept_file_exts]): + logger.info(f"Copying back {filename}") + shutil.copy(filename, here) + + finally: + os.chdir(here) + + logger.info("Removing temporary directory") + cleanup_after_timeout() + shutil.rmtree(tmpdir_path) + + return result + + return wrapped_function + + return func_decorator + + +def log_time(prefix: str = "Executed in: ", units: str = "ms") -> Callable: + """A function requiring a number of atoms to run""" + + if units.lower() == "s" or units.lower() == "seconds": + s_to_units = 1.0 + + elif units.lower() == "ms" or units.lower() == "milliseconds": + s_to_units = 1000.0 + + else: + raise ValueError(f"Unsupported time unit: {units}") + + def func_decorator(func): + @wraps(func) + def wrapped_function(*args, **kwargs): + start_time = time() + + result = func(*args, **kwargs) + + logger.info( + f"{prefix} " + f"{(time() - start_time) * s_to_units:.2f} {units}" + ) + + return result + + return wrapped_function + + return func_decorator + + +def requires_atoms(func: Callable) -> Callable: + """A function requiring a number of atoms to run""" + + @wraps(func) + def wrapped_function(*args, **kwargs): + # Species must be the first argument + assert hasattr(args[0], "n_atoms") + + if args[0].n_atoms == 0: + raise NoAtomsInMolecule + + return func(*args, **kwargs) + + return wrapped_function + + +def requires_graph(func: Callable) -> Callable: + """A function requiring a number of atoms to run""" + + @wraps(func) + def wrapped_function(*args, **kwargs): + # Species must be the first argument + assert hasattr(args[0], "graph") + + if args[0].graph is None: + raise NoMolecularGraph + + return func(*args, **kwargs) + + return wrapped_function + + +def requires_conformers(func: Callable) -> Callable: + """A function requiring the species to have a list of conformers""" + + @wraps(func) + def wrapped_function(*args, **kwargs): + # Species must be the first argument + assert hasattr(args[0], "n_conformers") + + if args[0].n_conformers == 0: + raise NoConformers + + return func(*args, **kwargs) + + return wrapped_function + + +def requires_hl_level_methods(func: Callable) -> Callable: + """A function requiring both high and low-level methods to be available""" + + @wraps(func) + def wrapped_function(*args, **kwargs): + from autode.methods import get_lmethod, get_hmethod + + suffix = "neither was available." + + try: + _ = get_lmethod() + + # Have a low-level method, so the high-level must not be available + suffix = "the high-level was not available." + _ = get_hmethod() + + except MethodUnavailable: + raise MethodUnavailable( + f"Function *{func.__name__}* requires both" + f" a high and low-level method but " + f"{suffix}" + ) + + return func(*args, **kwargs) + + return wrapped_function + + +def requires_output(func: Callable) -> Callable: + """A function requiring an output file and output file lines""" + + @wraps(func) + def wrapped_function(*args, **kwargs): + # Calculation must be the first argument + assert hasattr(args[0], "output") + + if args[0].output.file_lines is None: + raise NoCalculationOutput + + return func(*args, **kwargs) + + return wrapped_function + + +def requires_output_to_exist(func: Callable) -> Callable: + """Calculation method requiring the output filename to be set""" + + @wraps(func) + def wrapped_function(*args, **kwargs): + calc = args[0] + + if not calc.output.exists: + raise CouldNotGetProperty( + f"Could not get property from " + f"{calc.name}. Has .run() been called?" + ) + return func(*args, **kwargs) + + return wrapped_function + + +def no_exceptions(func) -> Any: + """Calculation method requiring the output filename to be set""" + + @wraps(func) + def wrapped_function(*args, **kwargs) -> Any: + try: + return func(*args, **kwargs) + except (ValueError, IndexError, TypeError, AutodeException): + return None + + return wrapped_function + + +def _cleanup_after_exp_timeout_win() -> None: + """ + If experimental timeout has been used, the ProcessPool has to be shutdown + otherwise File permission errors will be caused on Windows + """ + if Config.use_experimental_timeout: + pool = loky.get_reusable_executor() + pool.shutdown() + + +def _timeout_experimental( + seconds: float, return_value: Optional[Any] = None +) -> Any: + """ + Function decorator that times-out after a number of seconds, if + Config.use_experimental_timeout = True, otherwise no timeout + (experimental version, works on Windows) + + Args: + seconds: The number of seconds to timeout + return_value: Value returned if the function times out + + Returns: + (Any): result of function | return_value + """ + + def func_runner(connector, func, args, kwargs): + connector.send(os.getpid()) + return func(*args, **kwargs) + + def decorator(func): + def wrapper(*args, **kwargs): + # if user does not want it, there is no timeout + if not Config.use_experimental_timeout: + return func(*args, **kwargs) + + pool = loky.get_reusable_executor(max_workers=1) + conn1, conn2 = multiprocessing.Pipe() + job = pool.submit(func_runner, conn1, func, args, kwargs) + job_pid = conn2.recv() + try: + res = job.result(timeout=seconds) + return res + except loky.TimeoutError: + if os.getpid() != job_pid: + os.kill(job_pid, signal.SIGTERM) + pool.shutdown(wait=True) + return return_value + + return wrapper + + return decorator + + +def _timeout_default( + seconds: float, return_value: Optional[Any] = None +) -> Any: + """ + Function decorator that times-out after a number of seconds + (default version, uses forking) + + Args: + seconds: The number of seconds to timeout + return_value: Value returned if the function times out + + Returns: + (Any): result of function | return_value + """ + + def handler(queue, func, args, kwargs): + queue.put(func(*args, **kwargs)) + + def decorator(func): + def wrapper(*args, **kwargs): + q = multiprocessing.Queue() + p = multiprocessing.Process( + target=handler, args=(q, func, args, kwargs) + ) + + if multiprocessing.current_process().daemon: + # Cannot run a subprocess in a daemon process - timeout is not + # possible + return func(*args, **kwargs) + + elif isinstance( + multiprocessing.get_context(), + multiprocessing.context.ForkContext, + ): + p.start() + + else: + logger.error("Failed to wrap function") + return func(*args, **kwargs) + + p.join(timeout=seconds) + + if p.is_alive(): + p.kill() + p.join() + return return_value + + else: + return q.get() + + return wrapper + + return decorator + + +def hashable(_method_name: str, _object: Any): + """Multiprocessing requires hashable top-level functions to be executed, + so convert a method into a top-level function""" + return getattr(_object, _method_name) + + +def run_in_tmp_environment(**kwargs) -> Callable: + """ + Apply a set of environment variables, execute a function and reset them + """ + + class EnvVar: + def __init__(self, name, val): + self.name = str(name) + self.val = os.getenv(str(name), None) + self.new_val = str(val) + + env_vars = [EnvVar(k, v) for k, v in kwargs.items()] + + def func_decorator(func): + @wraps(func) + def wrapped_function(*args, **_kwargs): + for env_var in env_vars: + logger.info(f"Setting the {env_var.name} to {env_var.new_val}") + os.environ[env_var.name] = env_var.new_val + + result = func(*args, **_kwargs) + + for env_var in env_vars: + if env_var.val is None: + # Remove from the environment + os.environ.pop(env_var.name) + else: + # otherwise set it back to the old value + os.environ[env_var.name] = env_var.val + + return result + + return wrapped_function + + return func_decorator + + +def deprecated(func: Callable) -> Callable: + @wraps(func) + def wrapped_function(*args, **kwargs): + warnings.warn( + "This function is deprecated and will be removed " + "in autodE v1.5.0", + DeprecationWarning, + stacklevel=2, + ) + return func(*args, **kwargs) + + return wrapped_function + + +def checkpoint_rxn_profile_step(name: str) -> Callable: + """ + Decorator for a function that will save a checkpoint file with the reaction state + at that point in time. If the checkpoint exists then the state will be reloaded + and the execution skipped + """ + + def func_decorator(func: Callable[["Reaction"], Any]): + @wraps(func) + def wrapped_function(reaction: "Reaction"): + filepath = os.path.join( + "checkpoints", f"{str(reaction)}_{name}.chk" + ) + if os.path.exists(filepath): + reaction.load(filepath) + return + + start_time = time() + result = func(reaction) + + if ( + time() - start_time < 1.0 + ): # If execution is < 1s don't checkpoint + return result + + if not os.path.exists("checkpoints"): + os.mkdir("checkpoints") + + reaction.save(filepath) + + return result + + return wrapped_function + + return func_decorator + + +class StringDict: + r""" + Immutable dictionary stored as a single string. For example:: + 'a = b c = d' + """ + _value_type: type = str + + def __init__(self, string: str, delim: str = " = "): + self._string = string + self._delim = delim + + def __getitem__(self, item: str) -> Any: + split_string = self._string.split(f"{item}{self._delim}") + try: + return self._value_type(split_string[1].split()[0]) + + except (ValueError, IndexError) as e: + raise IndexError( + f"Failed to extract {item} from {self._string} " + f"using delimiter *{self._delim}*" + ) from e + + def __contains__(self, item: str) -> bool: + split_string = self._string.split(f"{item}{self._delim}") + return len(split_string) == 2 + + def get(self, item: str, default: Any) -> Any: + """Get an item or return a default""" + + try: + return self[item] + except IndexError: + return default + + def __str__(self): + return self._string + + +class NumericStringDict(StringDict): + _value_type = float + + +if platform.system() == "Windows": + # On Win64 or Win32, use loky + import loky + import loky.backend.resource_tracker + + try: + loky.backend.context.set_start_method("loky") + except RuntimeError: + logger.warning("Loky context has already been set") + # start the resource tracker early to fix file permission errors + if isinstance( + loky.backend.context.get_context(), loky.backend.context.LokyContext + ): + loky.backend.resource_tracker.ensure_running() + + class ProcessPool(loky.ProcessPoolExecutor): + def __init__( + self, + max_workers=None, + job_reducers=None, + result_reducers=None, + timeout=None, + context=None, + env=None, + ): + super().__init__( + max_workers=max_workers, + job_reducers=job_reducers, + result_reducers=result_reducers, + timeout=timeout, + context=context, + initializer=_copy_into_current_config, + initargs=(Config,), + env=env, + ) + + timeout = _timeout_experimental + cleanup_after_timeout = _cleanup_after_exp_timeout_win + +else: + # On Linux, macOS, use concurrent futures, which has multiprocessing backend + try: + multiprocessing.set_start_method("fork") + except RuntimeError: + logger.warning("Multiprocessing context has already been set") + + from concurrent.futures import ProcessPoolExecutor + + ProcessPool = ProcessPoolExecutor # type: ignore + timeout = _timeout_default + + def cleanup_after_timeout(): + pass diff --git a/autodE/source/autode/values.py b/autodE/source/autode/values.py new file mode 100644 index 0000000000000000000000000000000000000000..cf29304339788188d9a7c381fd57434015ff55f1 --- /dev/null +++ b/autodE/source/autode/values.py @@ -0,0 +1,816 @@ +# mypy: disable-error-code="override, type-var" +import numpy as np +from abc import ABC, abstractmethod +from copy import deepcopy +from collections.abc import Iterable +from autode.log import logger + +# fmt: off +from autode.units import ( + Unit, ha, m, ang_amu_half, ha_per_a0, ev_per_ang, + kjmol, kcalmol, rad, deg, kcalmol_per_ang, byte, + ev, J, wavenumber, hz, MB, ha_per_ang, + ang, a0, amu, kg, GB, kg_m_sq, + nm, pm, m_e, amu_ang_sq, TB, ha_per_a0_sq, + kelvin, celsius, + ha_per_ang_sq, J_per_m_sq, J_per_ang_sq, J_per_ang_sq_kg, +) +from typing import Any, Union, Type, Optional, Sequence, List, TypeVar, TYPE_CHECKING +# fmt: on + +if TYPE_CHECKING: + from autode.wrappers.methods import Method + from autode.wrappers.keywords.keywords import Keywords + +TypeValue = TypeVar("TypeValue", bound="Value") +TypeEnergy = TypeVar("TypeEnergy", bound="Energy") + + +def _to( + value: Union["Value", "ValueArray"], units: Union[Unit, str], inplace: bool +) -> Any: + """ + Convert a value or value array to a new unit and return a copy if + inplace=False + + --------------------------------------------------------------------------- + Arguments: + value: + + units: New units that the + + Returns: + (autode.values.Value): + """ + if value.units == units: + return value + + if value.units is None: + raise RuntimeError("Cannot convert with units=None") + + try: + units = next( + imp_unit + for imp_unit in value.implemented_units + if units.lower() in imp_unit.aliases + ) + + except StopIteration: + raise TypeError( + f"No viable unit conversion from {value.units} -> {units}" + ) + + if not (isinstance(value, Value) or isinstance(value, ValueArray)): + raise ValueError( + f"Cannot convert {value} to new units. Must be one of" + f" Value of ValueArray" + ) + + if isinstance(value, Value) and inplace: + raise ValueError( + "Cannot modify a value inplace as floats are immutable" + ) + + new_value = value if inplace else value.copy() + new_value *= units.times / value.units.times + new_value += value.units.add - units.add + new_value.units = units + + return None if inplace else new_value + + +def _units_init(value, units: Union[Unit, str, None]) -> Optional[Unit]: + """Initialise the units of this value + + Arguments: + units (Unit | str | None) + + Raises: + (ValueError): If this is not a valid unit for this value + """ + if units is None: + return None + + try: + return next( + unit + for unit in value.implemented_units + if units.lower() in unit.aliases + ) + + except StopIteration: + raise ValueError( + f"{units} is not a valid unit for " + f"{type(value).__name__}. Only " + f"{value.implemented_units} are implemented" + ) + + +class Value(ABC, float): + """ + Abstract base class for a value with a defined set of units, along with + perhaps other attributes and methods + + x = Value(0.0) + """ + + implemented_units: Sequence[Unit] = [] + + def __init__(self, x: Any, units: Union[Unit, str, None] = None): + """ + Value constructor + + ----------------------------------------------------------------------- + Arguments: + x (float | int): + + Keyword Arguments: + units (autode.units.Unit | str | None): + """ + + float.__init__(float(x)) + self.units: Optional[Unit] = None + + if isinstance(x, Value): + self.units = x.units + else: + self.units = _units_init(self, units) + + def __new__(cls, *args, **kwargs): + return float.__new__(cls, args[0]) + + @abstractmethod + def __repr__(self): + """Internal representation of this value""" + + def __str__(self): + """String representation of this value""" + # NOTE: as this may be used in f-strings just return the value as a str + return str(float(self)) + + def copy(self): + """Copy this value, with its units""" + return deepcopy(self) + + def _other_same_units(self, other): + """ + Convert another value to these units, do nothing if not a Value + + ----------------------------------------------------------------------- + Arguments: + other (autode.values.Value | float): + + Returns: + (float): + """ + if not isinstance(other, Value): + return other + + return other.to(self.units) + + def _like_self_from_float(self, value: float) -> TypeValue: + new_value = self.__class__(value, units=self.units) + new_value.__dict__.update(self.__dict__) + return new_value # type: ignore + + def __eq__(self, other: Any) -> bool: + """Equality of two values, which may be in different units""" + + if other is None: + return False + + if isinstance(other, Value): + other = other.to(self.units) + + return abs(float(self) - float(other)) < 1e-8 + + def __ne__(self, other: Any) -> bool: + return not self.__eq__(other) + + def __lt__(self, other: Any) -> bool: + """Less than comparison operator""" + + if isinstance(other, Value): + return float(self) < other.to(self.units) + + return float(self) < other + + def __gt__(self, other: Any) -> bool: + """Greater than comparison operator""" + return not self.__lt__(other) + + def __le__(self, other: Any) -> bool: + """Greater than or equal to comparison operator""" + return self.__lt__(other) or self.__eq__(other) + + def __ge__(self, other: Any) -> bool: + """Less than or equal to comparison operator""" + return self.__gt__(other) or self.__eq__(other) + + def __add__(self, other: Any) -> TypeValue: + """Add another value onto this one""" + if isinstance(other, np.ndarray): + return other + float(self) + + return self._like_self_from_float( + float(self) + self._other_same_units(other) + ) + + def __mul__(self, other) -> Union[float, TypeValue]: + """Multiply this value with another""" + if isinstance(other, np.ndarray): + return other * float(self) + + if isinstance(other, Value): + logger.warning( + "Multiplying autode.Value returns a float with no units" + ) + return float(self) * self._other_same_units(other) + + return self._like_self_from_float( + float(self) * self._other_same_units(other) + ) + + def __rmul__(self, other) -> Union[float, TypeValue]: + return self.__mul__(other) + + def __radd__(self, other) -> TypeValue: + return self.__add__(other) + + def __sub__(self, other) -> TypeValue: + return self.__add__(-other) + + def __neg__(self) -> TypeValue: + """Unary negation operation""" + return self._like_self_from_float(-float(self)) + + def __floordiv__(self, other) -> Union[float, TypeValue]: + x = float(self) // self._other_same_units(other) + return x if isinstance(other, Value) else self._like_self_from_float(x) + + def __truediv__(self, other) -> Union[float, TypeValue]: + x = float(self) / self._other_same_units(other) + return x if isinstance(other, Value) else self._like_self_from_float(x) + + def __abs__(self) -> TypeValue: + """Absolute value""" + return self if self > 0 else self * -1 # type: ignore + + def to(self, units): + """Convert this value to a new unit, returning a copy + + ----------------------------------------------------------------------- + Arguments: + units (autode.units.Unit | str): + + Returns: + (autode.values.Value): + + Raises: + (TypeError): + """ + return _to(self, units, inplace=False) + + +class Temperature(Value): + """Temperature in some units, defaults to Kelvin""" + + implemented_units = [kelvin, celsius] + + def __init__(self, value, units=kelvin): + super().__init__(value, units=units) + + def __repr__(self): + return f"Temperature({round(self, 2)} {self.units.name})" + + +class Energy(Value): + """Type of energy in some units e.g. Potential, Free etc. + defaults to Hartrees""" + + implemented_units = [ha, kcalmol, kjmol, ev, J] + + def __init__( + self, + value: Any, + units: Union[Unit, str] = ha, + method: Optional["Method"] = None, + keywords: Optional["Keywords"] = None, + estimated: bool = False, + ): + """ + Energy as a value. Has a method_str attribute which is set using a + method used to calculate the energy along with any keywords e.g. + PBE0/def2-SVP used to calculate it + + ---------------------------------------------------------------------- + Arguments: + + value: Float-able number + + units (autode.units.Unit): Unit type, allowing conversion + + method (autode.wrappers.methods.Method): + + keywords (autode.wrappers.keywords.Keywords | None): Set of + keywords which this energy has been calculated at + + estimated (bool): Has this energy been estimated rather than + calculated + """ + super().__init__(value, units=units) + + self.is_estimated = estimated + self.method_str = method_string(method, keywords) + + def __repr__(self) -> str: + if self.units is None: + return f"Energy({round(self, 5)} *no units*)" + else: + return f"Energy({round(self, 5)} {self.units.name})" + + def __eq__(self, other: Any) -> bool: + """Is an energy equal to another? Compares only the value, with + implicit unit conversion""" + tol_ha = 0.0000159 # 0.01 kcal mol-1 + + # A PotentialEnergy is not equal to a FreeEnergy, for example + if isinstance(other, Value) and not isinstance(other, self.__class__): + return False + + if isinstance(other, Value): + other = other.to("Ha") + + try: + other = float(other) # Must be float-able + except TypeError: + return False + + return abs(other - float(self.to("Ha"))) < tol_ha + + def set_method_str( + self, + method: Optional["Method"], + keywords: Optional["Keywords"], + ) -> None: + self.method_str = method_string(method, keywords) + + +class PotentialEnergy(Energy): + """Potential electronic energy (0 K, no zero-point energy)""" + + +class FreeEnergy(Energy): + """(Gibbs) Free Energy (G)""" + + def __repr__(self): + return f"FreeEnergy({round(self, 5)} {self.units.name})" + + +class Enthalpy(Energy): + """Enthalpy (H)""" + + def __repr__(self): + return f"Enthalpy({round(self, 5)} {self.units.name})" + + +class EnthalpyCont(Energy): + """Enthalpy contribution: H = E + H_cont""" + + def __repr__(self): + return f"H_cont({round(self, 5)} {self.units.name})" + + +class FreeEnergyCont(Energy): + """Free energy contribution: G = E + G_cont""" + + def __repr__(self): + return f"G_cont({round(self, 5)} {self.units.name})" + + +class Allocation(Value): + implemented_units = [byte, MB, GB, TB] + + def __repr__(self): + return f"Allocation({round(self, 1)} {self.units.name})" + + def __init__(self, x, units: Union[Unit, str] = MB): + """ + Allocation of memory or disk, must be non-negative + + Arguments: + x (float): + + Keyword Arguments: + units (autode.units.Unit | str | None): + """ + if float(x) <= 0: + raise ValueError( + "Memory allocations must be non-negative. " f"Had: {x}" + ) + + super().__init__(x=x, units=units) + + +class Energies(list): + """List of energies on an identical geometry/structure""" + + def append(self, other: Energy) -> None: + """ + Add another energy to this list, if it does not already appear + + ----------------------------------------------------------------------- + Arguments: + other (autode.values.Energy): + """ + + for item in self: + if other == item: + logger.debug( + f"Not appending {other} to the energies - " + f"already present. Moving to the end" + ) + self.append(self.pop(self.index(item))) + return + + return super().append(other) + + @staticmethod + def _next(energies: Any, energy_type: Type): + """Next type of energy in a list of energies""" + try: + return next( + energy + for energy in energies + if isinstance(energy, energy_type) + ) + + except StopIteration: + return None + + def last(self, energy_type: Type[Energy]) -> Optional[TypeEnergy]: + """ + Return the last instance of a particular energy type in these list + of energies + + ----------------------------------------------------------------------- + Arguments: + energy_type (Energy): + + Returns: + (autode.values.Energy | None): Energy + """ + return self._next(reversed(self), energy_type=energy_type) + + def first(self, energy_type: Type[Energy]) -> Optional[TypeEnergy]: + """ + Return the last instance of a particular energy type in these list + of energies + + ----------------------------------------------------------------------- + Arguments: + energy_type (Energy): + + Returns: + (autode.values.Energy | None): Energy + """ + return self._next(self, energy_type=energy_type) + + @property + def first_potential(self) -> Optional[PotentialEnergy]: + """ + First potential energy in this list + + ----------------------------------------------------------------------- + Returns: + (autode.values.PotentialEnergy | None): + """ + return self.first(energy_type=PotentialEnergy) + + @property + def last_potential(self) -> Optional[PotentialEnergy]: + """ + First potential energy in this list + + ----------------------------------------------------------------------- + Returns: + (autode.values.PotentialEnergy | None): + """ + return self.last(energy_type=PotentialEnergy) + + def copy(self): + return deepcopy(self) + + def __init__(self, *args: Energy): + """ + + Arguments: + *args (autode.values.Energy): + """ + super().__init__(args) + + +class Distance(Value): + """Distance in some units, defaults to Angstroms""" + + implemented_units = [ang, a0, pm, nm, m] + + def __repr__(self): + return f"Distance({round(self, 5)} {self.units.name})" + + def __init__(self, value, units=ang): + super().__init__(value, units=units) + + +class MWDistance(Value): + """Mass-weighted distance in some units, defaults to angstroms amu^(1/2)""" + + implemented_units = [ang_amu_half] + + def __repr__(self): + return f"Mass-weighted Distance({round(self, 5)} {self.units.name})" + + def __init__(self, value, units=ang_amu_half): + super().__init__(value, units=units) + + +class Angle(Value): + """Angle in some units, defaults to radians""" + + implemented_units = [rad, deg] + + def __repr__(self): + return f"Angle({round(self, 5)} {self.units.name})" + + def __init__(self, value, units=rad): + super().__init__(value, units=units) + + +class Frequency(Value): + implemented_units = [wavenumber, hz] + + @property + def is_imaginary(self) -> bool: + """Imaginary frequencies are quoted as negative for simplicity""" + return self < 0 + + @property + def real(self) -> "Frequency": + """ + A frequencies real (positive) value + + ----------------------------------------------------------------------- + Returns: + (autode.values.Frequency): + """ + return Frequency(-float(self)) if self.is_imaginary else self + + def __repr__(self): + return f"Frequency({round(self, 5)} {self.units.name})" + + def __init__(self, value, units=wavenumber): + super().__init__(value, units=units) + + +class Mass(Value): + implemented_units = [amu, kg, m_e] + + def __repr__(self): + return f"Mass({round(self, 5)} {self.units.name})" + + def __init__(self, value, units=amu): + super().__init__(value, units=units) + + +class ForceConstant(Value): + implemented_units = [ + ha_per_ang_sq, + ha_per_a0_sq, + J_per_m_sq, + J_per_ang_sq, + J_per_ang_sq_kg, + ] + + def __repr__(self): + return f"Force constant({round(self, 5)} {self.units.name})" + + def __init__(self, value, units=ha_per_ang_sq): + super().__init__(value, units=units) + + +class ValueArray(ABC, np.ndarray): + """ + Abstract base class for an array of values, e.g. gradients or a Hessian + """ + + implemented_units: List[Unit] = [] + + @abstractmethod + def __repr__(self): + """String representation of this value array""" + + def __eq__(self, other): + """Define equality for a valuearray, with implicit type conversion""" + + if isinstance(other, ValueArray): + other = other.to(self.units) + + eq = ( + other is not None + and hasattr(other, "shape") + and other.shape == self.shape + and np.allclose( + np.asarray(self), np.asarray(other), atol=1e-64, rtol=1e-64 + ) + ) + return eq + + def __ne__(self, other): + return not self.__eq__(other) + + def __new__( + cls, + input_array: Union[np.ndarray, Sequence], + units: Union[Unit, str, None] = None, + ) -> Any: + """ + Initialise a ValueArray from a numpy array, or another ValueArray + + ----------------------------------------------------------------------- + Arguments: + input_array (np.ndarray | autode.values.ValueArray): + + units (autode.units.Unit | str): + + Returns: + (autode.values.ValueArray): + """ + + arr = np.array(input_array, copy=True).view(cls) + + if isinstance(input_array, ValueArray) and units is None: + arr.units = input_array.units + else: + arr.units = _units_init(cls, units) + + return arr + + def __reduce__(self): + numpy_state = super().__reduce__() + return ( + numpy_state[0], + numpy_state[1], + tuple(numpy_state[2]) + (self.__dict__,), + ) + + def __setstate__(self, state, *args, **kwargs): + """Extend default pickling protocol to include extra attributes from ValueArray""" + + try: + self.__dict__.update(state[-1]) + super().__setstate__(state[:-1], *args, **kwargs) + except TypeError: + # This is a fallback so we can load old .npz files in mlptrain, see: + # https://github.com/duartegroup/autodE/issues/372 + super().__setstate__(state, *args, **kwargs) + + def to(self, units) -> Any: + """ + Convert this array to a new unit, returning a copy + + ----------------------------------------------------------------------- + Arguments: + units (autode.units.Unit | str): + + Returns: + (autode.values.ValueArray): + + Raises: + (TypeError): + """ + return _to(self, units, inplace=False) + + def to_(self, units) -> None: + """ + Convert this array into a set of new units, inplace. This will not copy + the array + + ----------------------------------------------------------------------- + Returns: + (None) + + Raises: + (TypeError): + """ + _to(self, units, inplace=True) + + def __array_finalize__(self, obj): + """See https://numpy.org/doc/stable/user/basics.subclassing.html""" + + if obj is None: + return + self.units = getattr(obj, "units", None) + + +class Coordinate(ValueArray): + implemented_units = [ang, a0, nm, pm, m] + + def __repr__(self): + return f"Coordinate({np.ndarray.__str__(self)} {self.units.name})" + + def __new__(cls, *args, units=ang): + if len(args) == 3: + return super().__new__(cls, np.asarray(args), units) + + elif ( + len(args) == 1 + and isinstance(args[0], Iterable) + and len(args[0]) == 3 + ): + # e.g. a numpy array or list of three elements + return super().__new__(cls, np.asarray(args[0]), units) + + else: + raise ValueError( + "Coordinate must be a 3 component vector, got " + f"{len(args)} component(s)" + ) + + @property + def x(self): + """x component in Cartesian space""" + return self[0] + + @property + def y(self): + """y component in Cartesian space""" + return self[1] + + @property + def z(self): + """z component in Cartesian space""" + return self[2] + + +class Coordinates(ValueArray): + implemented_units = [ang, a0, nm, pm, m] + + def __repr__(self): + return f"Coordinates({np.ndarray.__str__(self)} {self.units.name})" + + def __new__(cls, input_array, units=ang) -> "Coordinates": + return super().__new__( + cls, np.asarray(input_array).reshape(-1, 3), units + ) + + +class Gradient(ValueArray): + implemented_units = [ha_per_ang, ha_per_a0, ev_per_ang, kcalmol_per_ang] + + def __repr__(self): + return f"Gradients({np.ndarray.__str__(self)} {self.units.name})" + + def __new__(cls, input_array, units=ha_per_ang): + return super().__new__( + cls, np.asarray(input_array).reshape(-1, 3), units + ) + + +class GradientRMS(Value): + implemented_units = [ha_per_ang, ha_per_a0, ev_per_ang] + + def __repr__(self): + return f"RMS(∇E)({round(self, 4)} {self.units.name})" + + def __init__(self, x, units: Union[Unit, str] = ha_per_ang): + super().__init__(x=x, units=units) + + +class MomentOfInertia(ValueArray): + implemented_units = [amu_ang_sq, kg_m_sq] + + def __repr__(self): + return f"I({np.ndarray.__str__(self)} {self.units.name})" + + def __new__(cls, input_array, units=amu_ang_sq): + return super().__new__(cls, input_array, units) + + +class EnergyArray(ValueArray): + implemented_units = [ha, ev, kcalmol, kjmol, J] + + def __repr__(self): + """Representation of the energies in a PES""" + return f"PES{self.ndim}d" + + +def method_string( + method: Optional["Method"], + keywords: Optional["Keywords"], +) -> str: + """ + Create a method string for a method and the keywords + """ + method_str = f"{method.name} " if method is not None else "unknown" + method_str += keywords.bstring if keywords is not None else "" + return method_str diff --git a/autodE/source/autode/wrappers/G09.py b/autodE/source/autode/wrappers/G09.py new file mode 100644 index 0000000000000000000000000000000000000000..625959a520d6f8422f17cbcfd163a39523acac1e --- /dev/null +++ b/autodE/source/autode/wrappers/G09.py @@ -0,0 +1,728 @@ +import numpy as np +import autode.wrappers.keywords as kws +import autode.wrappers.methods + +from typing import List, TYPE_CHECKING +from copy import deepcopy +from autode.constants import Constants +from autode.utils import run_external +from autode.opt.optimisers.base import ExternalOptimiser +from autode.values import PotentialEnergy, Coordinates, Gradient +from autode.hessians import Hessian +from autode.geom import symm_matrix_from_ltril +from autode.config import Config +from autode.exceptions import AtomsNotFound, CouldNotGetProperty +from autode.log import logger +from autode.constraints import Constraints +from autode.utils import work_in_tmp_dir + +if TYPE_CHECKING: + from autode.calculations.executors import CalculationExecutor + from autode.opt.optimisers.base import BaseOptimiser + + +def _add_opt_option(keywords, new_option): + for keyword in keywords: + if "opt" not in keyword.lower(): + continue + + opt_options = [] + if "=(" in keyword: + # get the individual options + unformated_options = keyword[5:-1].split(",") + opt_options = [ + option.lower().strip() for option in unformated_options + ] + + elif "=" in keyword: + opt_options = [keyword[4:]] + + if not any(op.lower() == new_option.lower() for op in opt_options): + opt_options.append(new_option) + + new_keyword = f'Opt=({", ".join(opt_options)})' + keywords.remove(keyword) + keywords.append(new_keyword) + + return None + + +def _modify_keywords_for_point_charges(keywords): + """For a list of Gaussian keywords modify to include z-matrix if not + already included. Required if point charges are included in the calc""" + logger.warning("Modifying keywords as point charges are present") + + keywords.append("Charge") + _add_opt_option(keywords, new_option="Z-Matrix") + + return None + + +def _n_ecp_elements(keywords, molecule): + """Number of elements that require an ECP""" + + ecp_kwd = keywords.ecp + + if ecp_kwd is None: + return 0 + + ecp_elems = set( + atom.label + for atom in molecule.atoms + if atom.atomic_number >= ecp_kwd.min_atomic_number + ) + + return len(ecp_elems) + + +def _get_keywords(calc_input, molecule): + """Modify the input keywords to try and fix some Gaussian's quirks""" + + new_keywords = [] # List of keywords as strings for this calculation + + for keyword in calc_input.keywords.copy(): + # Replace the basis set file specification with genecp + if str(keyword).endswith(".gbs"): + logger.info("Found a custom basis set file adding genecp") + new_keywords.append("genecp") + continue + + if ( + isinstance(keyword, kws.BasisSet) + and _n_ecp_elements(calc_input.keywords, molecule) > 0 + ): + logger.info("Required and ECP so will print a custom basis set") + new_keywords.append("genecp") + continue + + elif isinstance(keyword, kws.ECP): + # ECPs are dealt with in a custom file + continue + + if isinstance(keyword, kws.MaxOptCycles): + continue # Handled after the full set of keywords is set + + elif isinstance(keyword, kws.Keyword): + kwd_str = keyword.g09 if getattr(keyword, "g09") else keyword.g16 + + # Add any empirical dispersion + if isinstance(keyword, kws.DispersionCorrection): + new_keywords.append(f"EmpiricalDispersion={kwd_str}") + + # and any other keywords, that may be a Keyword with a g09/g16 + # attribute or just a name + else: + new_keywords.append(kwd_str) + + else: + new_keywords.append(str(keyword)) + + # Mod redundant keywords is required if there are any constraints or + # modified internal coordinates + if molecule.constraints.any: + new_keywords.append("Geom=ModRedun") + + if calc_input.added_internals is not None: + new_keywords.append("Geom=ModRedun") + + # Remove the optimisation keyword if there is only a single atom + opt = False + for keyword in new_keywords: + if "opt" not in keyword.lower(): + continue + + opt = True + + if molecule.n_atoms == 1: + logger.warning("Cannot do an optimisation for a single atom") + new_keywords.remove(keyword) + + # Further modification is required if there are surrounding point charges + if calc_input.point_charges is not None: + _modify_keywords_for_point_charges(new_keywords) + + if isinstance(calc_input.keywords, kws.OptKeywords): + max_cycles = calc_input.keywords.max_opt_cycles + + if max_cycles is not None: + _add_opt_option(new_keywords, f"MaxCycles={int(max_cycles)}") + + # By default perform all optimisations without symmetry + if opt and not any(kw.lower() == "nosymm" for kw in new_keywords): + if hasattr(molecule, "is_linear") and molecule.is_linear(): + # Allow symmetry for linear molecules so the free energy + # calculation doesn't fail + pass + else: + new_keywords.append("NoSymm") + + return new_keywords + + +def _print_point_charges(inp_file, calc_input): + """Add point charges to the input file""" + + if calc_input.point_charges is None: + return + + print("\n", end="", file=inp_file) + for point_charge in calc_input.point_charges: + x, y, z = point_charge.coord + print( + f"{x:^12.8f} {y:^12.8f} {z:^12.8f} {point_charge.charge:^12.8f}", + file=inp_file, + ) + return + + +def _print_added_internals(inp_file, calc_input): + """Add any internal coordinates to the input file""" + + if calc_input.added_internals is None: + return + + for i, j in calc_input.added_internals: + # Gaussian indexes atoms from 1 + print("B", i + 1, j + 1, file=inp_file) + + return + + +def _print_constraints(inp_file, molecule): + """Add any distance or cartesian constraints to the input file""" + + if molecule.constraints.distance is not None: + for (i, j), dist in molecule.constraints.distance.items(): + # Gaussian indexes atoms from 1 + print("B", i + 1, j + 1, dist, "B", file=inp_file) + print("B", i + 1, j + 1, "F", file=inp_file) + + if molecule.constraints.cartesian is not None: + for i in molecule.constraints.cartesian: + # Gaussian indexes atoms from 1 + print("X", i + 1, "F", file=inp_file) + return + + +def _print_custom_basis(inp_file, calc_input, molecule): + """Print the definition of the custom basis set file""" + keywords = calc_input.keywords + + for keyword in keywords: + if isinstance(keyword, kws.Keyword) and getattr(keyword, "g09"): + str_keyword = keyword.g09 + else: + str_keyword = str(keyword) + + if str_keyword.endswith(".gbs"): + print(f"@{keyword}", file=inp_file) + return + + if _n_ecp_elements(keywords, molecule) == 0: + return + + # Must need a custom basis set file because there are ECPs to print + ecp_kwd, basis_kwd = keywords.ecp, keywords.basis_set + + if ecp_kwd is None or basis_kwd is None: + raise RuntimeError( + "Expecting to print a custom basis set file with " + "both a basis set and an ECP" + ) + + ecp_elems = set( + atom.label + for atom in molecule.atoms + if atom.atomic_number >= ecp_kwd.min_atomic_number + ) + + other_elems = set( + atom.label for atom in molecule.atoms if atom.label not in ecp_elems + ) + + print("@basis.gbs", file=inp_file) + + # Keyword strings that could be defined as either G09 or G16 + ecp_str = ecp_kwd.g09 if getattr(ecp_kwd, "g09") else ecp_kwd.g16 + basis_str = basis_kwd.g09 if getattr(basis_kwd, "g09") else basis_kwd.g16 + + with open("basis.gbs", "w") as basis_file: + if len(other_elems) > 0: + print(*other_elems, "0", file=basis_file) + print(f"{basis_str}", "****", sep="\n", file=basis_file) + + print(*ecp_elems, "0", file=basis_file) + print( + f"{ecp_str}", + "****", + "", + " ".join(ecp_elems) + " 0", + f"{ecp_str}", + sep="\n", + file=basis_file, + ) + + calc_input.additional_filenames.append("basis.gbs") + return None + + +def _rerun_angle_failure(calc): + """ + Gaussian will sometimes encounter a 180 degree angle and crash. This + function performs a few geometry optimisation cycles in cartesian + coordinates then switches back to internals + + Arguments: + calc (autode.calculation.Calculation): + + Returns: + (autode.calculation.Calculation): + """ + from autode.calculations import CalculationOutput + + cart_calc = deepcopy(calc) + + # Iterate through a copied set of keywords + for keyword in cart_calc.input.keywords.copy(): + if keyword.lower().startswith("geom"): + cart_calc.input.keywords.remove(keyword) + + elif keyword.lower().startswith("opt"): + options = [] + if "=(" in keyword: + # get the individual options + options = [ + option.lower().strip() + for option in keyword[5:-1].split(",") + ] + + for option in options: + if option.startswith("maxcycles") or option.startswith( + "maxstep" + ): + options.remove(option) + + elif "=" in keyword: + options = [keyword[4:]] + options += ["maxcycles=3", "maxstep=1", "cartesian"] + + new_keyword = f'Opt=({", ".join(options)})' + cart_calc.input.keywords.remove(keyword) + cart_calc.input.keywords.append(new_keyword) + + # Generate the new calculation and run + cart_calc.name += "_cartesian" + cart_calc.molecule.constraints = Constraints(distance=None, cartesian=None) + cart_calc.molecule.reset_graph() + cart_calc.output = CalculationOutput() + cart_calc.run() + + if not cart_calc.terminated_normally: + logger.warning("Cartesian calculation did not converge") + return None + + logger.info("Returning to internal coordinates") + + # Reset the required parameters for the new calculation + fixed_calc = deepcopy(calc) + fixed_calc.name += "_internal" + fixed_calc.output = CalculationOutput() + fixed_calc.run() + + return fixed_calc + + +def _run_hessian(calc): + """ + Run a hessian calculation based on a corresponding optimisation or + single point calculation. Used for when an external force driver is used + and the dummy basis set does not carry over to the frequency calculation. + See: https://github.com/duartegroup/autodE/pull/86 + + Arguments: + calc (autode.calculation.Calculation): + + Returns: + (autode.calculation.Calculation): + """ + from autode.calculations import CalculationOutput + + hess_calc = deepcopy(calc) # Uses a copy so the current calc. is unchanged + + # Remove any optimisation keywords + for keyword in filter( + lambda kwd: "opt" in kwd.lower(), hess_calc.input.keywords + ): + hess_calc.input.keywords.remove(keyword) + + # Add Geom(Redundant) to be compatible with External + hess_calc.input.keywords.append("Freq Geom(Redundant)") + + # Generate the new calculation and run + hess_calc.name += "_hess" + hess_calc.molecule.constraints = Constraints(distance=None, cartesian=None) + hess_calc.output = CalculationOutput() + hess_calc.run() + + return hess_calc + + +def _freq_in_keywords(calc): + """Is 'Freq' in a a set of keywords used to run a calculation""" + return any("freq" in keyword.lower() for keyword in calc.input.keywords) + + +def _calc_uses_external_method(calc): + """Does this Gaussian calculation use an external force driver?""" + return any("external" in kwd.lower() for kwd in calc.input.keywords) + + +class G09(autode.wrappers.methods.ExternalMethodOEGH): + def __init__( + self, + executable_name="g09", + path=None, + keywords_set=None, + implicit_solvation_type=None, + ): + """Gaussian 09""" + + if keywords_set is None: + keywords_set = Config.G09.keywords + + if implicit_solvation_type is None: + implicit_solvation_type = Config.G09.implicit_solvation_type + + super().__init__( + executable_name=executable_name, + path=Config.G09.path if path is None else path, + keywords_set=keywords_set, + implicit_solvation_type=implicit_solvation_type, + doi_list=["http://gaussian.com/citation/"], + ) + + def __repr__(self): + return f"Gaussian09(available = {self.is_available})" + + def generate_input_for(self, calc) -> None: + """Print a Gaussian input file""" + molecule = calc.molecule + + with open(calc.input.filename, "w") as inp_file: + # Gaussian defines the total memory for the whole calculation, not + # per core + total_mem = int(Config.max_core.to("MB") * calc.n_cores) + print(f"%mem={total_mem}MB", file=inp_file) + + if calc.n_cores > 1: + print(f"%nprocshared={calc.n_cores}", file=inp_file) + + keywords = _get_keywords(calc.input, molecule) + print("#", *keywords, file=inp_file, end=" ") + + if molecule.solvent is not None: + print( + f"scrf=(smd,solvent={molecule.solvent.g09})", file=inp_file + ) + else: + print("", file=inp_file) + + print(f"\n {calc.name}\n", file=inp_file) + print(molecule.charge, molecule.mult, file=inp_file) + + for atom in molecule.atoms: + x, y, z = atom.coord + print( + f"{atom.label:<3} {x:^12.8f} {y:^12.8f} {z:^12.8f}", + file=inp_file, + ) + + _print_point_charges(inp_file, calc.input) + print("", file=inp_file) + _print_added_internals(inp_file, calc.input) + _print_constraints(inp_file, molecule) + + if molecule.constraints.any or calc.input.added_internals: + print("", file=inp_file) # needs an extra blank line + _print_custom_basis(inp_file, calc.input, molecule) + + # Gaussian needs blank lines at the end of the file + print("\n", file=inp_file) + + return None + + @staticmethod + def input_filename_for(calc: "CalculationExecutor") -> str: + return f"{calc.name}.com" + + @staticmethod + def output_filename_for(calc: "CalculationExecutor") -> str: + return f"{calc.name}.log" + + def version_in(self, calc) -> str: + """Get the version of Gaussian used in this calculation""" + + for line in calc.output.file_lines: + if line.startswith("Gaussian ") and "Revision" in line: + return line.lstrip("Gaussian ") + + logger.warning("Could not find the Gaussian version number") + return "???" + + def execute(self, calc) -> None: + @work_in_tmp_dir( + filenames_to_copy=calc.input.filenames, + kept_file_exts=(".log", ".com", ".gbs"), + ) + def execute_g09(): + run_external( + params=[calc.method.path, calc.input.filename], + output_filename=calc.output.filename, + ) + + execute_g09() + return None + + def terminated_normally_in(self, calc, rerun_if_failed=True): + termination_strings = [ + "Normal termination of Gaussian", + "Number of steps exceeded", + ] + + bend_ok = True # Gaussian can fail when 180º bends are encountered + for line in reversed(calc.output.file_lines): + if any(string in line for string in termination_strings): + logger.info("Gaussian terminated normally") + return True + + if "Bend failed for angle" in line: + logger.warning("Gaussian encountered a 180° angle and crashed") + bend_ok = False + break + + if bend_ok or not rerun_if_failed: + return False + + # Set a limit on the amount of times we do this + if calc.name.endswith("internal_internal_internal_internal"): + return False + + try: + # To fix the calculation requires the atoms to be in the output + fixed_calc = _rerun_angle_failure(calc) + + except AtomsNotFound: + return False + + if fixed_calc is not None and fixed_calc.terminated_normally: + logger.info("The 180° angle issue has been fixed") + calc.output = fixed_calc.output + calc.name = fixed_calc.name + return True + + return False + + def _energy_from(self, calc: "CalculationExecutor") -> PotentialEnergy: + for line in reversed(calc.output.file_lines): + if "SCF Done" in line or "E(CIS)" in line: + return PotentialEnergy((line.split()[4]), units="Ha") + + if "E(CORR)" in line or "E(CI)" in line: + return PotentialEnergy(line.split()[3], units="Ha") + + if "E(CIS(D))" in line: + return PotentialEnergy(line.split()[5], units="Ha") + + if line.startswith(" Energy=") and "NIter=" in line: + return PotentialEnergy(line.split()[1], units="Ha") + + raise CouldNotGetProperty(name="energy") + + def optimiser_from(self, calc: "CalculationExecutor") -> "BaseOptimiser": + return G09Optimiser(output_lines=calc.output.file_lines) + + def coordinates_from(self, calc: "CalculationExecutor") -> Coordinates: + """Get the final set of coordinates from a G09 output""" + return self._coordinates_from(calc) + + @staticmethod + def _coordinates_from(calc: "CalculationExecutor") -> Coordinates: + coords: List[List[float]] = [] + + for i, line in enumerate(calc.output.file_lines): + if "Input orientation" in line: + coords.clear() + xyz_lines = calc.output.file_lines[ + i + 5 : i + 5 + calc.molecule.n_atoms + ] + + for xyz_line in xyz_lines: + _, _, _, x, y, z = xyz_line.split() + coords.append([float(x), float(y), float(z)]) + + if len(coords) == 0: + raise CouldNotGetProperty(name="coordinates") + + return Coordinates(coords, units="Å") + + def partial_charges_from(self, calc: "CalculationExecutor") -> List[float]: + charges_section = False + charges: List[float] = [] + for line in reversed(calc.output.file_lines): + if "sum of mulliken charges" in line.lower(): + charges_section = True + + if len(charges) == calc.molecule.n_atoms: + return list(reversed(charges)) + + if charges_section and len(line.split()) == 3: + charges.append(float(line.split()[2])) + + logger.error("Something went wrong finding the atomic charges") + return charges + + def gradient_from(self, calc: "CalculationExecutor") -> Gradient: + """ + Get gradients from a Gaussian output file in the format + + + ------------------------------------------------------------------- + Center Atomic Forces (Hartrees/Bohr) + Number Number X Y Z + ------------------------------------------------------------------- + 1 6 -0.000205102 0.000074692 0.000073625 + . . . . . + """ + n_atoms = calc.molecule.n_atoms + raw_gradient: List[np.ndarray] = [] + + for i, line in enumerate(calc.output.file_lines): + if "Forces (Hartrees/Bohr)" not in line: + continue + + raw_gradient = [] # NOTE: possibly multiple gradients in a file + + for force_line in calc.output.file_lines[i + 3 : i + 3 + n_atoms]: + try: + _, _, fx, fy, fz = force_line.split() + force = np.array([float(fx), float(fy), float(fz)]) + + grad = -force / Constants.a0_to_ang + raw_gradient.append(grad) + + except ValueError: + logger.warning("Failed to set gradient line") + + return Gradient(raw_gradient, units="Ha a0^-1").to("Ha Å^-1") + + def hessian_from( + self, calc: "autode.calculations.executors.CalculationExecutor" + ) -> Hessian: + r""" + Extract the Hessian from a Gaussian09 calculation, which is printed as + just the lower triangular portion but is symmetric so the full 3Nx3N + matrix can be re-constructed. Read it from the final output block + sandwiched between 1\1\ ..... \\@ + + Arguments: + calc (autode.calculation.Calculation): + + Returns: + (autode.hessians.Hessian): + + Raises: + (IndexError | ValueError): + """ + assert calc.input.keywords is not None, "Must have keywords" + + if _calc_uses_external_method(calc) and not _freq_in_keywords(calc): + # Using external force drivers can lead to failed Hessian calcs. + calc = _run_hessian(calc) + + hess_lines = [] + append_line = False + + for line in reversed(calc.output.file_lines): + if ( + r"\\@" in line + or line.startswith(" @") + or line.startswith(r" \@") + ): + append_line = True + + if append_line: + # Strip off new-lines and spaces + hess_lines.append(line.strip("\n").strip(" ")) + + if "NImag" in line: + break + + r""" + For a block with the format: + + ...[C*(O1C1O1)]\NImag=0\\H_x1x1, H_y1x1, ...\\ + F_x1, F_y1, ...\\\@ + + get the elements of the Hessian, noting that the lines have been + parsed backwards, hence the [::-1] + """ + + hess_str = "".join(hess_lines[::-1]).split(r"\\")[-3] + hess_values = [float(val) for val in hess_str.split(",")] + + n = 3 * calc.molecule.n_atoms + + if len(hess_values) != n * (n + 1) // 2: + raise CouldNotGetProperty( + "Not enough elements of the Hessian matrix found" + ) + + """ + NOTE: Output file for Hessian may contain only Standard orientation + coordinates, which break Hessian projection - so in those cases use + the original set of coordinates. + """ + try: + atoms = self.atoms_from(calc) + except CouldNotGetProperty: + atoms = calc.molecule.atoms.copy() + + return Hessian( + symm_matrix_from_ltril(hess_values), + atoms=atoms, + functional=calc.input.keywords.functional, + units="Ha a0^-2", + ).to("Ha Å^-2") + + +class G09Optimiser(ExternalOptimiser): + def __init__(self, output_lines: List[str]): + self._lines = output_lines + + @property + def converged(self) -> bool: + """Has the optimisation converged?""" + + for line in reversed(self._lines): + if "Optimization completed" in line: + return True + + return False + + @property + def last_energy_change(self) -> "PotentialEnergy": + """Find the last energy change in the file""" + + energies = [] + for line in self._lines: + if "SCF Done" in line or "E(CIS)" in line: + energy_str = line.split()[4] + energies.append(PotentialEnergy(energy_str, units="Ha")) + + if len(energies) < 2: + return PotentialEnergy(np.inf) + + return energies[-1] - energies[-2] + + +g09 = G09() diff --git a/autodE/source/autode/wrappers/G16.py b/autodE/source/autode/wrappers/G16.py new file mode 100644 index 0000000000000000000000000000000000000000..2c541fed2221c196fd48341fee9861a55cafca0f --- /dev/null +++ b/autodE/source/autode/wrappers/G16.py @@ -0,0 +1,20 @@ +from autode.wrappers.G09 import G09 +from autode.config import Config + + +class G16(G09): + """Gaussian 16 seems to have the same syntax as Gaussian 09""" + + def __init__(self): + super().__init__( + executable_name="g16", + path=Config.G16.path, + keywords_set=Config.G16.keywords, + implicit_solvation_type=Config.G16.implicit_solvation_type, + ) + + def __repr__(self): + return f"Gaussian16(available = {self.is_available})" + + +g16 = G16() diff --git a/autodE/source/autode/wrappers/MOPAC.py b/autodE/source/autode/wrappers/MOPAC.py new file mode 100644 index 0000000000000000000000000000000000000000..03c67006f232d7f2d8a7fcafa25e3717dc6672d0 --- /dev/null +++ b/autodE/source/autode/wrappers/MOPAC.py @@ -0,0 +1,370 @@ +import os +import numpy as np +import autode.wrappers.keywords as kwds +import autode.wrappers.methods + +from typing import List, TYPE_CHECKING + +from autode.opt.optimisers.base import ExternalOptimiser +from autode.values import PotentialEnergy, Gradient, Coordinates +from autode.utils import run_external +from autode.exceptions import NotImplementedInMethod +from autode.config import Config +from autode.constants import Constants +from autode.exceptions import UnsupportedCalculationInput +from autode.log import logger +from autode.utils import work_in_tmp_dir +from autode.exceptions import CouldNotGetProperty + + +if TYPE_CHECKING: + from autode.calculations.executors import CalculationExecutor + from autode.opt.optimisers.base import BaseOptimiser + + +def get_keywords(calc_input, molecule): + """Get the keywords to use for a MOPAC calculation""" + # To determine if there is an optimisation or single point the keywords + # needs to be a subclass of Keywords + assert isinstance(calc_input.keywords, kwds.Keywords) + + keywords = [ + kwd + for kwd in calc_input.keywords.copy() + if not isinstance(kwd, kwds.MaxOptCycles) + ] + + if isinstance(calc_input.keywords, kwds.SinglePointKeywords): + # Single point calculation add the 1SCF keyword to prevent opt + if not any("1scf" in kw.lower() for kw in keywords): + keywords.append("1SCF") + + if isinstance(calc_input.keywords, kwds.GradientKeywords): + # Gradient calculation needs GRAD + if not any("grad" in kw.lower() for kw in keywords): + keywords.append("GRAD") + + # Gradient calculation add the 1SCF keyword to prevent opt + if not any("1scf" in kw.lower() for kw in keywords): + keywords.append("1SCF") + + if calc_input.point_charges is not None: + keywords.append("QMMM") + + if molecule.solvent is not None: + if molecule.solvent.dielectric is None: + err_str = ( + f"Could not use solvent {molecule.solvent} for MOPAC " + f"calculation, a dielectric constant was not defined" + ) + raise UnsupportedCalculationInput(message=err_str) + + keywords.append(f"EPS={molecule.solvent.dielectric}") + + # Add the charge and multiplicity + keywords.append(f"CHARGE={molecule.charge}") + + if "ENPART" not in keywords: + keywords.append("ENPART") # Print an energy partition, and also E_tot + + if molecule.mult != 1: + if molecule.mult == 2: + keywords.append("DOUBLET") + elif molecule.mult == 3: + keywords.append("OPEN(2,2)") + else: + logger.critical("Unsupported spin multiplicity") + raise UnsupportedCalculationInput + + return keywords + + +def get_atoms_and_fixed_atom_indexes(molecule): + """ + MOPAC seemingly doesn't have the capability to defined constrained bond + lengths, so perform a linear interpolation to the atoms then fix the + Cartesians + + Arguments: + molecule (any): + + Returns: + (tuple): List of non-fixed atoms and fixed atoms + """ + fixed_atoms = [] + + if molecule.constraints.distance is None: + return molecule.atoms, fixed_atoms + + bonds = list(molecule.constraints.distance.keys()) + distances = list(molecule.constraints.distance.values()) + + # Get a set of atoms that have been shifted using a linear interpolation + atoms = _get_atoms_linear_interp( + atoms=molecule.atoms, bonds=bonds, final_distances=distances + ) + + # Populate a flat list of atom ids to fix + fixed_atoms = [i for bond in bonds for i in bond] + + return atoms, fixed_atoms + + +def print_atoms(inp_file, atoms, fixed_atom_idxs): + """Print the atoms to the input file depending on whether they are fixed""" + + for i, atom in enumerate(atoms): + x, y, z = atom.coord + + if i in fixed_atom_idxs: + line = f"{atom.label:<3}{x:^10.5f} 0 {y:^10.5f} 0 {z:^10.5f} 0" + else: + line = f"{atom.label:<3}{x:^10.5f} 1 {y:^10.5f} 1 {z:^10.5f} 1" + + print(line, file=inp_file) + return + + +def print_point_charges(calc, atoms): + """Print a point charge file if there are point charges""" + + if calc.input.point_charges is None: + return + + potentials = [] + for atom in atoms: + potential = 0 + coord = atom.coord + for point_charge in calc.input.point_charges: + # V = q/r_ij + potential += point_charge.charge / np.linalg.norm( + coord - point_charge.coord + ) + + # Distance in Å need to be converted to a0 and then the energy + # Ha e^-1 to kcal mol-1 e^-1 + potentials.append( + Constants.ha_to_kcalmol * Constants.a0_to_ang * potential + ) + + with open(f"{calc.name}_mol.in", "w") as pc_file: + print(f"\n{len(atoms)} 0", file=pc_file) + + for potential in potentials: + print(f"0 0 0 0 {potential}", file=pc_file) + + calc.input.additional_filenames.append(f"{calc.name}_mol.in") + return + + +def _get_atoms_linear_interp( + atoms, bonds, final_distances +) -> "autode.atoms.Atoms": + """For a geometry defined by a set of xyzs, set the constrained bonds to + the correct lengths + + --------------------------------------------------------------------------- + Arguments: + atoms (list(autode.atoms.Atom)): list of atoms + + bonds (list(tuple)): List of bond ids on for which the final_distances + apply + final_distances (list(float)): List of final bond distances for the + bonds + + Returns: + (list(autode.atoms.Atom)): Shifted atoms + """ + + coords = np.array([atom.coord for atom in atoms]) + atoms_and_shift_vecs = {} + + for n, bond in enumerate(bonds): + atom_a, atom_b = bond + ab_vec = coords[atom_b] - coords[atom_a] + d_crr = np.linalg.norm(ab_vec) + d_final = final_distances[n] + + ab_norm_vec = ab_vec / d_crr + + atoms_and_shift_vecs[atom_b] = 0.5 * (d_final - d_crr) * ab_norm_vec + atoms_and_shift_vecs[atom_a] = -0.5 * (d_final - d_crr) * ab_norm_vec + + for n, coord in enumerate(coords): + if n in atoms_and_shift_vecs.keys(): + coord += atoms_and_shift_vecs[n] + + atoms[n].coord = coord + + return atoms + + +class MOPAC(autode.wrappers.methods.ExternalMethodOEG): + def __init__(self): + super().__init__( + executable_name="mopac", + path=Config.MOPAC.path, + keywords_set=Config.MOPAC.keywords, + implicit_solvation_type=Config.MOPAC.implicit_solvation_type, + doi_list=["10.1007/BF00128336"], + ) + + def __repr__(self): + return f"MOPAC(available = {self.is_available})" + + def generate_input_for(self, calc: "CalculationExecutor") -> None: + molecule = calc.molecule + assert calc.input.filename, "Filename must be defined" + + with open(calc.input.filename, "w") as input_file: + keywords = get_keywords(calc.input, molecule) + print(*keywords, "\n\n", file=input_file) + + atoms, fixed_atom_idxs = get_atoms_and_fixed_atom_indexes(molecule) + + if molecule.constraints.cartesian is not None: + fixed_atom_idxs += molecule.constraints.cartesian + + print_atoms(input_file, atoms, fixed_atom_idxs) + print_point_charges(calc, atoms) + + return None + + @staticmethod + def input_filename_for(calc: "CalculationExecutor") -> str: + return f"{calc.name}.mop" + + @staticmethod + def output_filename_for(calc: "CalculationExecutor") -> str: + return f"{calc.name}.out" + + def version_in(self, calc: "CalculationExecutor") -> str: + """Get the version of MOPAC used to execute this calculation""" + + for line in calc.output.file_lines: + if "(Version:" in line and len(line.split()) >= 3: + # e.g. MOPAC2016 (Version: 19.144L) + + try: + name = line.split()[0] + # Item between the brackets with only the version number + version = line.split("(")[1].split(")")[0].split()[1] + + return f"{name} {version}" + + except IndexError: + pass + + logger.warning("Could not get the version number from the output file") + return "???" + + def execute(self, calc): + @work_in_tmp_dir( + filenames_to_copy=calc.input.filenames, + kept_file_exts=(".mop", ".out"), + use_ll_tmp=True, + ) + def execute_mopac(): + logger.info(f"Setting the number of OMP threads to {calc.n_cores}") + os.environ["OMP_NUM_THREADS"] = str(calc.n_cores) + run_external( + params=[calc.method.path, calc.input.filename], + output_filename=calc.output.filename, + ) + + execute_mopac() + return None + + def terminated_normally_in(self, calc: "CalculationExecutor") -> bool: + n_errors = 0 + + for i, line in enumerate(reversed(calc.output.file_lines)): + if "Error" in line: + n_errors += 1 + + if i == 100: + break + + return n_errors == 0 + + def _energy_from(self, calc: "CalculationExecutor") -> PotentialEnergy: + def _energy(x): + return PotentialEnergy(x, units="eV").to("Ha") + + for line in calc.output.file_lines: + if "ETOT (EONE + ETWO)" in line: + return _energy(line.split()[-2]) + + if "TOTAL ENERGY" in line: + return _energy(line.split()[3]) + + raise CouldNotGetProperty(name="energy") + + def optimiser_from(self, calc: "CalculationExecutor") -> "BaseOptimiser": + is_converged = any( + "GRADIENT" in l and "IS LESS THAN CUTOFF" in l + for l in reversed(calc.output.file_lines) + ) + return MOPACOptimiser(converged=is_converged) + + def coordinates_from(self, calc: "CalculationExecutor") -> Coordinates: + coords: List[List[float]] = [] + n_atoms = calc.molecule.n_atoms + + for i, line in enumerate(calc.output.file_lines): + if i == len(calc.output.file_lines) - 3: + # At the end of the file + break + + line_length = len(calc.output.file_lines[i + 3].split()) + + if "CARTESIAN COORDINATES" in line and line_length == 5: + # CARTESIAN COORDINATES + # + # 1 C 1.255660629 0.020580974 -0.276235553 + + coords = [] + xyz_lines = calc.output.file_lines[i + 2 : i + 2 + n_atoms] + for xyz_line in xyz_lines: + x, y, z = xyz_line.split()[2:] + coords.append([float(x), float(y), float(z)]) + + return Coordinates(coords, units="Å") + + def partial_charges_from(self, calc: "CalculationExecutor") -> List[float]: + raise NotImplementedInMethod + + def gradient_from(self, calc: "CalculationExecutor") -> Gradient: + gradients_section = False + raw = [] + for line in calc.output.file_lines: + if "FINAL POINT AND DERIVATIVES" in line: + gradients_section = True + + if gradients_section and "ATOM CHEMICAL" in line: + gradients_section = False + + if gradients_section and len(line.split()) == 8: + _, _, _, _, _, _, value, _ = line.split() + try: + raw.append(float(value)) + except ValueError: + raise CouldNotGetProperty(name="gradients") + + return Gradient(raw, units="kcal mol^-1 Å^-1").to("Ha Å^-1") + + +class MOPACOptimiser(ExternalOptimiser): + def __init__(self, converged: bool): + self._converged = converged + + @property + def converged(self) -> bool: + return self._converged + + @property + def last_energy_change(self) -> "PotentialEnergy": + raise NotImplementedError + + +mopac = MOPAC() diff --git a/autodE/source/autode/wrappers/NWChem.py b/autodE/source/autode/wrappers/NWChem.py new file mode 100644 index 0000000000000000000000000000000000000000..bdcbf2ec3310cd9df963b466445de150cb40db9e --- /dev/null +++ b/autodE/source/autode/wrappers/NWChem.py @@ -0,0 +1,453 @@ +import numpy as np +import autode.wrappers.keywords as kws +import autode.wrappers.methods + +from typing import TYPE_CHECKING, List + +from autode.utils import run_external_monitored +from autode.values import PotentialEnergy, Gradient, Coordinates +from autode.hessians import Hessian +from autode.geom import symm_matrix_from_ltril +from autode.config import Config +from autode.exceptions import UnsupportedCalculationInput, CouldNotGetProperty +from autode.log import logger +from autode.utils import work_in_tmp_dir + + +if TYPE_CHECKING: + from autode.calculations.executors import CalculationExecutor + + +def ecp_block(molecule, keywords): + """ + Generate a block of input for any effective core potentials to add + + Arguments: + molecule (autode.species.Species): + keywords (autode.wrappers.keywords.Keywords): + + Returns: + (str): + """ + ecp_kwd = keywords.ecp + + if ecp_kwd is None: + return "" # No ECP is defined in these keywords + + # Set of unique atomic symbols that require an ECP + ecp_elems = set( + atom.label + for atom in molecule.atoms + if atom.atomic_number >= ecp_kwd.min_atomic_number + ) + + if len(ecp_elems) == 0: + return "" # No atoms require an ECP + + ecp_str = "\necp\n" + ecp_str += "\n".join( + f" {label} library {ecp_kwd.nwchem}" for label in ecp_elems + ) + ecp_str += "\nend" + + return ecp_str + + +def get_keywords(calc_input, molecule): + """Generate a keywords list and adding solvent""" + + new_keywords = [] + + for keyword in calc_input.keywords: + if "scf" in keyword.lower(): + if molecule.solvent is not None: + raise UnsupportedCalculationInput( + "NWChem only supports " "solvent for DFT calcs" + ) + + if isinstance(keyword, kws.Functional): + keyword = f"dft\n maxiter 100\n xc {keyword.nwchem}\nend" + + elif isinstance(keyword, kws.BasisSet): + keyword = f"basis\n * library {keyword.nwchem}\nend" + keyword += ecp_block(molecule, keywords=calc_input.keywords) + + elif isinstance(keyword, kws.ECP): + # ECPs are added to the basis block + continue + + elif isinstance(keyword, kws.MaxOptCycles): + continue # Maximum number of optimisation cycles in driver block + + elif isinstance(keyword, kws.Keyword): + keyword = keyword.nwchem + + if "opt" in keyword.lower() and molecule.n_atoms == 1: + logger.warning("Cannot do an optimisation for a single atom") + + # Replace any 'opt' containing word in this keyword with energy + words = [] + for word in keyword.split(): + if "opt" in word: + words.append("energy") + else: + words.append(word) + + new_keywords.append(" ".join(words)) + + elif keyword.lower().startswith("dft"): + lines = keyword.split("\n") + lines.insert(1, f" mult {molecule.mult}") + new_keyword = "\n".join(lines) + new_keywords.append(new_keyword) + + elif keyword.lower().startswith("scf"): + if not any("nopen" in kw for kw in new_keywords): + lines = keyword.split("\n") + lines.insert(1, f" nopen {molecule.mult - 1}") + new_keywords.append("\n".join(lines)) + + elif "driver" in keyword.lower() and isinstance( + calc_input.keywords, kws.OptKeywords + ): + raise UnsupportedCalculationInput( + f"NWChem uses autodE implemented optimisers. {keyword} will " + f"be unused" + ) + + else: + new_keywords.append(keyword) + + if any("task scf" in kw.lower() for kw in new_keywords) and not any( + "nopen" in kw.lower() for kw in new_keywords + ): + # Need to set the spin state + new_keywords.insert(1, f"scf\n nopen {molecule.mult - 1}\nend") + + return new_keywords + + +class NWChem(autode.wrappers.methods.ExternalMethodEGH): + def __init__(self): + super().__init__( + executable_name="nwchem", + path=Config.NWChem.path, + keywords_set=Config.NWChem.keywords, + implicit_solvation_type=Config.NWChem.implicit_solvation_type, + doi_list=["10.1063/5.0004997"], + ) + + def __repr__(self): + return f"NWChem(available = {self.is_available})" + + def generate_input_for(self, calc: "CalculationExecutor") -> None: + assert calc.input.filename is not None, "Must have an input filename" + molecule = calc.molecule + keywords = get_keywords(calc.input, molecule) + + with open(calc.input.filename, "w") as inp_file: + print(f"start {calc.name}\necho", file=inp_file) + + if calc.molecule.solvent is not None: + print( + f"cosmo\n " + f"do_cosmo_smd true\n " + f"solvent {calc.molecule.solvent.nwchem}\n" + f"end", + file=inp_file, + ) + + print("geometry noautosym", end=" ", file=inp_file) + if molecule.constraints.distance or molecule.constraints.cartesian: + print("noautoz", file=inp_file) + else: + print("", file=inp_file) + + for atom in molecule.atoms: + x, y, z = atom.coord + print( + f"{atom.label:<3} {x:^12.8f} {y:^12.8f} {z:^12.8f}", + file=inp_file, + ) + + print("end", file=inp_file) + + print(f"charge {calc.molecule.charge}", file=inp_file) + + if calc.input.point_charges is not None: + print("bq", file=inp_file) + for pc in calc.input.point_charges: + x, y, z = pc.coord + print( + f"{x:^12.8f} {y:^12.8f} {z:^12.8f} {pc.charge:^12.8f}", + file=inp_file, + ) + print("end", file=inp_file) + + print(f'memory {int(Config.max_core.to("MB"))} mb', file=inp_file) + + print(*keywords, sep="\n", file=inp_file) + + # Will used partial an ESP initialisation to generate partial + # atomic charges - more accurate than the standard Mulliken + # analysis (or at least less sensitive to the method) + print("task esp", file=inp_file) + + return None + + @staticmethod + def input_filename_for(calc: "CalculationExecutor") -> str: + return f"{calc.name}.nw" + + @staticmethod + def output_filename_for(calc: "CalculationExecutor") -> str: + return f"{calc.name}.out" + + def version_in(self, calc: "CalculationExecutor") -> str: + """Get the NWChem version from the output file""" + for line in calc.output.file_lines: + if "(NWChem)" in line: + # e.g. Northwest Computational Chemistry Package (NWChem) 6.6 + return line.split()[-1] + + logger.warning("Could not find the NWChem version") + return "???" + + def execute(self, calc: "CalculationExecutor"): + @work_in_tmp_dir( + filenames_to_copy=calc.input.filenames, + kept_file_exts=(".nw", ".out"), + ) + def execute_nwchem(): + params = [ + "mpirun", + "-np", + str(calc.n_cores), + calc.method.path, + calc.input.filename, + ] + + run_external_monitored( + params, + calc.output.filename, + break_words=["Received an Error", "MPI_ABORT"], + ) + + execute_nwchem() + return None + + def terminated_normally_in(self, calc: "CalculationExecutor") -> bool: + for n_line, line in enumerate(reversed(calc.output.file_lines)): + if any( + substring in line + for substring in [ + "CITATION", + "Failed to converge in maximum number of steps or available time", + ] + ): + logger.info("nwchem terminated normally") + return True + if "MPI_ABORT" in line: + return False + + if n_line > 500: + return False + + return False + + def _energy_from(self, calc: "CalculationExecutor") -> PotentialEnergy: + wf_strings = [ + "Total CCSD energy", + "Total CCSD(T) energy", + "Total SCS-MP2 energy", + "Total MP2 energy", + "Total RI-MP2 energy", + ] + + for line in reversed(calc.output.file_lines): + if any( + string in line + for string in ["Total DFT energy", "Total SCF energy"] + ): + return PotentialEnergy(line.split()[4], units="Ha") + + if any(string in line for string in wf_strings): + return PotentialEnergy(line.split()[3], units="Ha") + + raise CouldNotGetProperty(name="energy") + + def coordinates_from(self, calc: "CalculationExecutor") -> Coordinates: + xyzs_section = False + coords: List[List[float]] = [] + + for line in calc.output.file_lines: + if "Output coordinates in angstroms" in line: + xyzs_section = True + coords.clear() + + if "Atomic Mass" in line: + xyzs_section = False + + if xyzs_section and len(line.split()) == 6: + if line.split()[0].isdigit(): + _, _, _, x, y, z = line.split() + coords.append([float(x), float(y), float(z)]) + + return Coordinates(coords, units="Å") + + def partial_charges_from(self, calc: "CalculationExecutor") -> List[float]: + """ + e.g. + Atom Coordinates Charge + + ESP + + + 1 C -0.000814 0.000010 0.001095 -0.266058 + . . . . . . + """ + charges_section = False + charges: List[float] = [] + + for line in calc.output.file_lines: + if ( + len(line.split()) == 3 + and "Atom" in line + and "Coordinates" in line + and "Charge" in line + ): + charges_section = True + charges.clear() + + if charges_section and len(line.split()) == 6: + charge = line.split()[-1] + charges.append(float(charge)) + + if charges_section and "------------" in line: + charges_section = False + + return charges + + def gradient_from(self, calc: "CalculationExecutor") -> Gradient: + gradients: List[np.ndarray] = [] + n_atoms = calc.molecule.n_atoms + + for i, line in enumerate(calc.output.file_lines): + if "DFT ENERGY GRADIENTS" not in line: + continue + + gradients = [] + + for grad_line in calc.output.file_lines[i + 4 : i + 4 + n_atoms]: + x, y, z = grad_line.split()[5:] + gradients.append(np.array([float(x), float(y), float(z)])) + + return Gradient(gradients, units="Ha a0^-1").to("Ha Å^-1") + + @staticmethod + def _atom_masses_from_hessian(calc: "CalculationExecutor") -> List[float]: + """ + Grab the atomic masses from the 'atom information' section, which + should be present from a Hessian calculation. Block looks like:: + + ---------------------------- Atom information ---------------- + atom # X Y Z mass + -------------------------------------------------------------- + O 1 0.0000D+00 0.000D+00 2.26367D-01 1.5994910D+01 + H 2 1.4235D+00 0.000D+00 -9.05466D-01 1.0078250D+00 + H 3 -1.4435D+00 0.000D+00 -9.05466D-01 1.0078250D+00 + + Returns: + (list(float)): + """ + n_atoms, file_lines = calc.molecule.n_atoms, calc.output.file_lines + atom_lines = None + + for i, line in enumerate(reversed(file_lines)): + if "Atom information" not in line: + continue + + atom_lines = file_lines[-i + 2 : -i + 2 + n_atoms] + break + + if atom_lines is None: + raise CouldNotGetProperty("No masses found in output file") + + # Replace double notation for standard 'E' and float all the final + # entries, which should be the masses in amu + return [ + float(line.split()[-1].replace("D", "E")) for line in atom_lines + ] + + def hessian_from(self, calc: "CalculationExecutor") -> Hessian: + """ + Get the un-mass weighted Hessian matrix from the calculation. Block + looks like:: + + ---------------------------------------------------- + MASS-WEIGHTED NUCLEAR HESSIAN (Hartree/Bohr/Bohr/Kamu) + ---------------------------------------------------- + + + 1 2 ..... + ----- ----- ----- ----- ----- + 1 4.25381D+01 + 2 -8.96428D-10 -4.68356D-04 + . . . . + + Arguments: + calc (autode.calculation.Calculation): + + Returns: + (np.ndarray): + """ + logger.info(f"Attempting to set the Hessian from {calc.name}") + + try: + line_idx = next( + i + for i, line in enumerate(calc.output.file_lines) + if "MASS-WEIGHTED NUCLEAR HESSIAN" in line + ) + except StopIteration: + raise CouldNotGetProperty("Hessian not found in the output file") + + hess_lines: List[List[float]] = [ + [] for _ in range(calc.molecule.n_atoms * 3) + ] + + for hess_line in calc.output.file_lines[line_idx + 6 :]: + if "NORMAL MODE EIGENVECTORS" in hess_line: + break # Finished the Hessian block + + if "D" in hess_line: + # e.g. 1 4.50945D-01 ... + idx = hess_line.split()[0] + try: + _ = hess_lines[int(idx) - 1] + except (ValueError, IndexError): + raise CouldNotGetProperty( + "Unexpected hessian formating: " f"{hess_line}" + ) + + values = [ + float(x) for x in hess_line.replace("D", "E").split()[1:] + ] + hess_lines[int(idx) - 1] += values + + atom_masses = self._atom_masses_from_hessian(calc) + hess = symm_matrix_from_ltril(array=hess_lines) + + # Un-mass weight from Kamu^-1 to 1 + mass_arr = np.repeat(atom_masses, repeats=3, axis=np.newaxis) * 1e-3 + hess *= np.sqrt(np.outer(mass_arr, mass_arr)) + + return Hessian( + hess, + atoms=self.atoms_from(calc), + functional=calc.input.keywords.functional, + units="Ha a0^-2", + ).to("Ha Å^-2") + + +nwchem = NWChem() diff --git a/autodE/source/autode/wrappers/ORCA.py b/autodE/source/autode/wrappers/ORCA.py new file mode 100644 index 0000000000000000000000000000000000000000..1732de645bab4b57d4e13dbed671928518f364de --- /dev/null +++ b/autodE/source/autode/wrappers/ORCA.py @@ -0,0 +1,625 @@ +import numpy as np +import os +import autode.wrappers.keywords as kws +import autode.wrappers.methods +from typing import List, TYPE_CHECKING + +from autode.utils import run_external +from autode.hessians import Hessian +from autode.opt.optimisers.base import ExternalOptimiser +from autode.values import PotentialEnergy, Gradient, Coordinates +from autode.input_output import xyz_file_to_atoms +from autode.config import Config +from autode.utils import work_in_tmp_dir +from autode.log import logger +from autode.exceptions import ( + UnsupportedCalculationInput, + CouldNotGetProperty, + NoCalculationOutput, + XYZfileWrongFormat, + AtomsNotFound, +) + +if TYPE_CHECKING: + from autode.calculations.executors import CalculationExecutor + from autode.opt.optimisers.base import BaseOptimiser + +vdw_gaussian_solvent_dict = { + "water": "Water", + "acetone": "Acetone", + "acetonitrile": "Acetonitrile", + "benzene": "Benzene", + "carbon tetrachloride": "CCl4", + "dichloromethane": "CH2Cl2", + "chloroform": "Chloroform", + "cyclohexane": "Cyclohexane", + "n,n-dimethylformamide": "DMF", + "dimethylsulfoxide": "DMSO", + "ethanol": "Ethanol", + "n-hexane": "Hexane", + "methanol": "Methanol", + "1-octanol": "Octanol", + "pyridine": "Pyridine", + "tetrahydrofuran": "THF", + "toluene": "Toluene", +} + + +def print_added_internals(inp_file, calc_input): + """Print the added internal coordinates""" + + if calc_input.added_internals is None: + return + + for i, j in calc_input.added_internals: + print( + "%geom\n" "modify_internal\n" "{ B", + i, + j, + "A } end\n" "end", + file=inp_file, + ) + return + + +def print_distance_constraints(inp_file, molecule): + """Print the distance constraints to the input file""" + if molecule.constraints.distance is None: + return + + print("%geom Constraints", file=inp_file) + for (i, j), dist in molecule.constraints.distance.items(): + print("{ B", i, j, dist, "C }", file=inp_file) + print(" end\nend", file=inp_file) + + return + + +def print_cartesian_constraints(inp_file, molecule): + """Print the Cartesian constraints to the input file""" + + if molecule.constraints.cartesian is None: + return + + print("%geom Constraints", file=inp_file) + for i in molecule.constraints.cartesian: + print("{ C", i, "C }", file=inp_file) + print(" end\nend", file=inp_file) + + return + + +def print_num_optimisation_steps(inp_file, molecule, calc_input): + """If there are relatively few atoms increase the number of opt steps""" + + if not isinstance(calc_input.keywords, kws.OptKeywords): + return # Not an optimisation so no need to increase steps + + if calc_input.keywords.max_opt_cycles is not None: + print( + f"%geom MaxIter {int(calc_input.keywords.max_opt_cycles)} end", + file=inp_file, + ) + return + + if molecule.n_atoms > 33: + return # Use default behaviour + + return + + +def print_point_charges(inp_file, calc_input): + """Print a point charge file and add the name to the input file""" + + if calc_input.point_charges is None: + return + + filename = calc_input.filename.replace(".inp", ".pc") + with open(filename, "w") as pc_file: + print(len(calc_input.point_charges), file=pc_file) + for pc in calc_input.point_charges: + x, y, z = pc.coord + print( + f"{pc.charge:^12.8f} {x:^12.8f} {y:^12.8f} {z:^12.8f}", + file=pc_file, + ) + + calc_input.additional_filenames.append(filename) + + print(f'% pointcharges "{filename}"', file=inp_file) + return + + +def print_default_params(inp_file): + """Print some useful default parameters to the input file""" + + print( + "%output \nxyzfile=True \nend ", + "%scf \nmaxiter 250 \nend", + "%output\nPrint[P_Hirshfeld] = 1\nend", + "% maxcore", + int(Config.max_core.to("MB")), + sep="\n", + file=inp_file, + ) + return + + +def print_coordinates(inp_file, molecule): + """Print the coordinates to the input file in the correct format""" + + print("*xyz", molecule.charge, molecule.mult, file=inp_file) + for atom in molecule.atoms: + x, y, z = atom.coord + print( + f"{atom.label:<3} {x:^12.8f} {y:^12.8f} {z:^12.8f}", file=inp_file + ) + print("*", file=inp_file) + + return + + +class ORCA(autode.wrappers.methods.ExternalMethodOEGH): + def __init__(self): + super().__init__( + executable_name="orca", + path=Config.ORCA.path, + keywords_set=Config.ORCA.keywords, + implicit_solvation_type=Config.ORCA.implicit_solvation_type, + doi_list=["10.1002/wcms.81", "10.1002/wcms.1327"], + ) + + def __repr__(self): + return f"ORCA(available = {self.is_available})" + + def generate_input_for(self, calc: "CalculationExecutor") -> None: + assert calc.input.filename is not None + + keywords = self.get_keywords(calc.input, calc.molecule) + assert len(keywords) > 0 + + with open(calc.input.filename, "w") as inp_file: + print("!", *keywords, file=inp_file) + + self.print_solvent(inp_file, calc.molecule, keywords) + print_added_internals(inp_file, calc.input) + print_distance_constraints(inp_file, calc.molecule) + print_cartesian_constraints(inp_file, calc.molecule) + print_num_optimisation_steps(inp_file, calc.molecule, calc.input) + print_point_charges(inp_file, calc.input) + print_default_params(inp_file) + + if calc.n_cores > 1: + print(f"%pal nprocs {calc.n_cores}\nend", file=inp_file) + + print_coordinates(inp_file, calc.molecule) + + return None + + @staticmethod + def input_filename_for(calc: "CalculationExecutor") -> str: + return f"{calc.name}.inp" + + @staticmethod + def output_filename_for(calc: "CalculationExecutor") -> str: + return f"{calc.name}.out" + + def version_in(self, calc: "CalculationExecutor") -> str: + """Get the version of ORCA used to execute this calculation""" + + if not calc.output.exists: + return self._get_version_no_output() + + for line in calc.output.file_lines: + if "Program Version" in line and len(line.split()) >= 3: + return line.split()[2] + + logger.warning("Could not find the ORCA version number") + return "???" + + def execute(self, calc): + @work_in_tmp_dir( + filenames_to_copy=calc.input.filenames, + kept_file_exts=Config.ORCA.copied_output_exts, + ) + def execute_orca(): + run_external( + params=[calc.method.path, calc.input.filename], + output_filename=calc.output.filename, + ) + + execute_orca() + return None + + def optimiser_from(self, calc: "CalculationExecutor") -> "BaseOptimiser": + return ORCAOptimiser(output_lines=calc.output.file_lines) + + def terminated_normally_in(self, calc: "CalculationExecutor") -> bool: + termination_strings = [ + "$end", # at the end of a .hess file + "ORCA TERMINATED NORMALLY", + "The optimization did not converge", + ] + + for n_line, line in enumerate(reversed(calc.output.file_lines)): + if any(substring in line for substring in termination_strings): + logger.info("orca terminated normally") + return True + + if n_line > 30: + # The above lines are pretty close to the end of the file – + # so skip parsing it all + return False + + return False + + def _energy_from(self, calc: "CalculationExecutor") -> PotentialEnergy: + assert calc.output.filename is not None, "Must have a set output" + + if calc.output.filename.endswith(".hess"): + logger.warning("Failed to set the potential energy") + return PotentialEnergy(0.0) + + for line in reversed(calc.output.file_lines): + if "FINAL SINGLE POINT ENERGY" in line: + return PotentialEnergy(line.split()[4], units="Ha") + + raise CouldNotGetProperty(name="energy") + + def coordinates_from(self, calc: "CalculationExecutor") -> Coordinates: + assert calc.output.filename is not None, "Must have a set output" + + fn_ext = ".hess" if calc.output.filename.endswith(".hess") else ".out" + + # First try the .xyz file generated + xyz_file_name = calc.output.filename.replace(fn_ext, ".xyz") + if os.path.exists(xyz_file_name): + try: + return xyz_file_to_atoms(xyz_file_name).coordinates + + except XYZfileWrongFormat: + raise AtomsNotFound(f"Failed to parse {xyz_file_name}") + + # Then the Hessian file + hess_file_name = calc.output.filename.replace(fn_ext, ".hess") + if os.path.exists(hess_file_name): + hess_file_lines = open(hess_file_name, "r").readlines() + + coords = [] + for i, line in enumerate(hess_file_lines): + if "$atoms" not in line: + continue + + for aline in hess_file_lines[ + i + 2 : i + 2 + calc.molecule.n_atoms + ]: + _, _, x, y, z = aline.split() + coords.append([float(x), float(y), float(z)]) + + return Coordinates(coords, units="a0").to("Å") + + # and finally the potentially long .out file + if os.path.exists(calc.output.filename) and fn_ext == ".out": + coords = [] + + # There could be many sets in the file, so take the last + for i, line in enumerate(calc.output.file_lines): + if "CARTESIAN COORDINATES (ANGSTROEM)" not in line: + continue + + coords, n_atoms = [], calc.molecule.n_atoms + for oline in calc.output.file_lines[i + 2 : i + 2 + n_atoms]: + _, x, y, z = oline.split() + coords.append([float(x), float(y), float(z)]) + + return Coordinates(coords, units="Å") + + raise NoCalculationOutput("Failed to find any ORCA output files") + + def partial_charges_from(self, calc: "CalculationExecutor") -> List[float]: + """ + e.g. + + .HIRSHFELD ANALYSIS + ------------------ + + Total integrated alpha density = 12.997461186 + Total integrated beta density = 12.997461186 + + ATOM CHARGE SPIN + 0 C -0.006954 0.000000 + . . . . + """ + charges: List[float] = [] + + for i, line in enumerate(calc.output.file_lines): + if "HIRSHFELD ANALYSIS" in line: + charges = [] + first, last = i + 7, i + 7 + calc.molecule.n_atoms + for charge_line in calc.output.file_lines[first:last]: + charges.append(float(charge_line.split()[-2])) + + return charges + + def gradient_from(self, calc: "CalculationExecutor") -> Gradient: + """ + e.g. + + #------------------ + CARTESIAN GRADIENT <- i + #------------------ + + 1 C : -0.011390275 -0.000447412 0.000552736 <- j + """ + gradients: List[List[float]] = [] + + for i, line in enumerate(calc.output.file_lines): + if ( + "CARTESIAN GRADIENT" in line + or "The final MP2 gradient" in line + ): + gradients = [] + if "CARTESIAN GRADIENT" in line: + first, last = i + 3, i + 3 + calc.molecule.n_atoms + if "The final MP2 gradient" in line: + first, last = i + 1, i + 1 + calc.molecule.n_atoms + if "CARTESIAN GRADIENT (NUMERICAL)" in line: + first, last = i + 2, i + 2 + calc.molecule.n_atoms + + for grad_line in calc.output.file_lines[first:last]: + if len(grad_line.split()) <= 3: + continue + + dadx, dady, dadz = grad_line.split()[-3:] + gradients.append([float(dadx), float(dady), float(dadz)]) + + return Gradient(gradients, units="Ha a0^-1").to("Ha Å^-1") + + @staticmethod + def _start_line_hessian(calc, file_lines): + """ + Find the line where the Hessian starts in an ORCA Hessian file + e.g. H2O.hess + + Arguments: + calc (autode.calculation.Calculation): + file_lines (list(str)): + + Returns: + (int): + + Raises: + (autode.exceptions.CouldNotGetProperty | AssertionError): + """ + + for i, line in enumerate(file_lines): + if "$hessian" not in line: + continue + + # Ensure the number of atoms is present, and is the number expected + n_atoms = int(file_lines[i + 1].split()[0]) // 3 + assert n_atoms == calc.molecule.n_atoms + return i + 3 + + raise CouldNotGetProperty(f"No Hessian found in the Hessian file") + + def hessian_from( + self, calc: "autode.calculations.executors.CalculationExecutor" + ) -> Hessian: + """Grab the Hessian from the output .hess file + + e.g.:: + + $hessian + 9 + 0 1 + 2 3 4 + 0 6.48E-01 4.376E-03 2.411E-09 -3.266E-01 -2.5184E-01 + . . . . . . + """ + assert calc.input.keywords is not None, "Must have keywords" + + assert calc.output.filename is not None, "Output filename must be set" + hess_filename = calc.output.filename + + if calc.output.filename.endswith(".out"): + hess_filename = calc.output.filename.replace(".out", ".hess") + + if not os.path.exists(hess_filename): + raise CouldNotGetProperty("Could not find Hessian file") + + file_lines = open(hess_filename, "r", encoding="utf-8").readlines() + + hessian_blocks = [] + start_line = self._start_line_hessian(calc, file_lines) + + for j, h_line in enumerate(file_lines[start_line:]): + if len(h_line.split()) == 0: + # Assume we're at the end of the Hessian + break + + # Skip blank lines in the file, marked by one or more fewer items + # than the previous + if len(h_line.split()) < len( + file_lines[start_line + j - 1].split() + ): + continue + + # First item is the coordinate number, thus append all others + hessian_blocks.append([float(v) for v in h_line.split()[1:]]) + + n_atoms = calc.molecule.n_atoms + hessian = [block for block in hessian_blocks[: 3 * n_atoms]] + + for i, block in enumerate(hessian_blocks[3 * n_atoms :]): + hessian[i % (3 * n_atoms)] += block + + return Hessian( + np.array(hessian), + atoms=calc.molecule.atoms, + functional=calc.input.keywords.functional, + units="Ha a0^-2", + ).to("Ha Å^-2") + + @work_in_tmp_dir(filenames_to_copy=[], kept_file_exts=[]) + def _get_version_no_output(self) -> str: + """ + Get the version of ORCA without an existing output file + """ + + try: + run_external( + params=[self.path, "-h"], + output_filename="tmp", + stderr_to_log=False, + ) + line = next(l for l in open("tmp", "r") if "Program Version" in l) + return line.split()[2] + + except (OSError, IOError, StopIteration): + return "???" + + def get_keywords(self, calc_input, molecule): + """Modify the keywords for this calculation with the solvent + fix for + single atom optimisation calls""" + kwds_cls = calc_input.keywords.__class__ + + new_keywords = kwds_cls() + + for keyword in calc_input.keywords: + if "scalfreq" in keyword.lower(): + raise UnsupportedCalculationInput( + "Frequency scaling within ORCA will not alter the " + "calculated frequencies. Use ade.Config.freq_scale_factor" + ) + + if "opt" in keyword.lower() and molecule.n_atoms == 1: + logger.warning("Can't optimise a single atom") + continue + + if isinstance(keyword, kws.ECP) and keyword.orca is None: + # Use the default specification for applying ECPs + continue + + if isinstance(keyword, kws.MaxOptCycles): + continue # Set in print_num_optimisation_steps + + if isinstance(keyword, kws.Keyword): + new_keywords.append(keyword.orca) + + else: + new_keywords.append(str(keyword)) + + if molecule.solvent is not None: + self.add_solvent_keyword(molecule, new_keywords) + + # Sort the keywords with all the items with newlines at the end, so + # the first keyword line is a single contiguous line + return kwds_cls( + sorted(new_keywords, key=lambda kw: 1 if "\n" in kw else 0) + ) + + def use_vdw_gaussian_solvent(self, keywords) -> bool: + """ + Determine if the calculation should use the gaussian charge scheme which + generally affords better convergence for optimiations in implicit solvent + + Arguments: + keywords (autode.wrappers.keywords.Keywords): + + Returns: + (bool): + """ + assert self.implicit_solvation_type is not None, "Must have a solvent" + + if self.implicit_solvation_type.lower() != "cpcm": + return False + + if keywords.contain_any_of("freq", "optts") and not self.is_v5: + logger.warning( + "Cannot do analytical frequencies with gaussian " + "charge scheme - switching off" + ) + return False + + return True + + def add_solvent_keyword(self, molecule, keywords): + """Add a keyword to the input file based on the solvent""" + + if self.implicit_solvation_type.lower() not in ["smd", "cpcm"]: + raise UnsupportedCalculationInput( + "Implicit solvent type must be " "either SMD or CPCM" + ) + + if ( + self.use_vdw_gaussian_solvent(keywords) + and molecule.solvent.orca not in vdw_gaussian_solvent_dict + ): + err = ( + f"CPCM solvent with gaussian charge not available for " + f"{molecule.solvent.name}. Available solvents are " + f"{vdw_gaussian_solvent_dict.keys()}" + ) + + raise UnsupportedCalculationInput(message=err) + + solv_name = vdw_gaussian_solvent_dict[molecule.solvent.orca] + keywords.append(f"CPCM({solv_name})") + return + + def print_solvent(self, inp_file, molecule, keywords): + """Add the solvent block to the input file""" + + if molecule.solvent is None: + return + + if self.implicit_solvation_type.lower() == "smd": + print( + f"%cpcm\n" + f"smd true\n" + f'SMDsolvent "{molecule.solvent.orca}"\n' + f"end", + file=inp_file, + ) + + if self.use_vdw_gaussian_solvent(keywords): + print("%cpcm\n" "surfacetype vdw_gaussian\n" "end", file=inp_file) + return + + @property + def is_v5(self): + """Is this ORCA version at least 5.0.0?""" + return self._get_version_no_output()[0] == "5" + + +class ORCAOptimiser(ExternalOptimiser): + def __init__(self, output_lines: List[str]): + self._lines = output_lines + + @property + def converged(self) -> bool: + """Has the optimisation converged?""" + + for line in reversed(self._lines): + if "THE OPTIMIZATION HAS CONVERGED" in line: + return True + + return False + + @property + def last_energy_change(self) -> "PotentialEnergy": + """Find the last energy change in the file""" + + energies = [] + for line in self._lines: + if "FINAL SINGLE POINT ENERGY" in line: + energies.append(PotentialEnergy(line.split()[4], units="Ha")) + + if len(energies) < 2: + return PotentialEnergy(np.inf) + + return energies[-1] - energies[-2] + + +orca = ORCA() diff --git a/autodE/source/autode/wrappers/QChem.py b/autodE/source/autode/wrappers/QChem.py new file mode 100644 index 0000000000000000000000000000000000000000..14e76760c4062509171aa225e05ee8068fb5383d --- /dev/null +++ b/autodE/source/autode/wrappers/QChem.py @@ -0,0 +1,609 @@ +import numpy as np +import autode.wrappers.keywords as kws +import autode.wrappers.methods +from typing import List, TYPE_CHECKING + +from autode.config import Config +from autode.values import PotentialEnergy, Gradient, Coordinates +from autode.log import logger +from autode.opt.optimisers.base import ExternalOptimiser +from autode.hessians import Hessian +from autode.utils import run_external, work_in_tmp_dir +from autode.exceptions import ( + CouldNotGetProperty, + NotImplementedInMethod, + UnsupportedCalculationInput, +) + +if TYPE_CHECKING: + from autode.calculations.executors import CalculationExecutor + from autode.opt.optimisers.base import BaseOptimiser + + +class QChem(autode.wrappers.methods.ExternalMethodOEGH): + """ + QChem Electronic Structure package. + + Tested versions: + 5.4.1 + + Website: https://www.q-chem.com/ + User manual: https://manual.q-chem.com/5.1/index.html + """ + + def __init__(self): + super().__init__( + executable_name="qchem", + path=Config.QChem.path, + keywords_set=Config.QChem.keywords, + implicit_solvation_type=Config.QChem.implicit_solvation_type, + doi_list=["10.1080/00268976.2014.952696"], + ) + + def __repr__(self): + return f"QChem(available = {self.is_available})" + + def generate_input_for(self, calc: "CalculationExecutor") -> None: + """Generate a QChem input file""" + assert calc.input.keywords is not None, "Must have input keywords" + molecule = calc.molecule + + if calc.input.filename is None: + raise ValueError( + f"Cannot generate an input for {calc}. Input " + "filename was undefined" + ) + + if ( + molecule.is_implicitly_solvated + and not self._keywords_contain(calc, "solvent_method") + and self.implicit_solvation_type is not None + ): + calc.input.keywords.append(self.implicit_solvation_type) + + if calc.input.point_charges is not None: + raise NotImplementedError( + "Point charges within QChem " + "calculations are not yet supported" + ) + + with self._InputFileWriter(filename=calc.input.filename) as inp_file: + inp_file.add_molecule_block(molecule) + + if self._is_ts_opt(calc): + # TS optimisations require an initial frequency calculation + inp_file.add_freq(calc) + inp_file.add_calculation_seperator() + + inp_file.add_rem_block(calc) + inp_file.add_solvent_block(calc) + inp_file.add_constraints(calc) + + if self._is_ts_opt(calc): + inp_file.add_molecule_read() + + # TS optimisation also require a final frequency calculation + inp_file.add_calculation_seperator() + inp_file.add_freq(calc) + inp_file.add_molecule_read() + + return None + + @staticmethod + def output_filename_for(calc: "CalculationExecutor") -> str: + return f"{calc.name}.out" + + @staticmethod + def input_filename_for(calc: "CalculationExecutor") -> str: + return f"{calc.name}.in" + + def version_in(self, calc: "CalculationExecutor") -> str: + """QChem version from a completed output file""" + + if not calc.output.exists: + return "???" + + for line in calc.output.file_lines: + if "Q-Chem" in line and len(line.split()) > 1: + # e.g. Q-Chem 5.4.1 for Intel X86 EM64T Linux + str0, str1 = line.split()[:2] + + if str0 == "Q-Chem" and "." in str1 and "," not in str1: + return str1 + + return "???" + + def execute(self, calc) -> None: + """Execute a qchem calculation""" + + @work_in_tmp_dir( + filenames_to_copy=calc.input.filenames, + kept_file_exts=(".in", ".out"), + ) + def execute_qchem(): + params = [self.path, "-nt", str(calc.n_cores), calc.input.filename] + run_external(params, output_filename=calc.output.filename) + + execute_qchem() + return None + + def terminated_normally_in(self, calc: "CalculationExecutor") -> bool: + """Did the calculation terminate normally?""" + + if not calc.output.exists: + logger.warning( + "QChem output did not exist - calculation " "did not terminate" + ) + return False + + calc_started = False + + for line in calc.output.file_lines: + if "Q-Chem begins" in line: + calc_started = True + + if "MAXIMUM OPTIMIZATION CYCLES REACHED" in line: + logger.info("Maximum number of optimisation steps reached") + return True + + if "fatal error" in line or "input file has failed" in line: + logger.error("Fatal error in QChem calculation. Final lines:") + calc.output.try_to_print_final_lines(n=50) + return False + + return True if calc_started else False + + def optimiser_from(self, calc: "CalculationExecutor") -> "BaseOptimiser": + return QChemOptimiser(output_lines=calc.output.file_lines) + + def coordinates_from(self, calc: "CalculationExecutor") -> Coordinates: + if not isinstance(calc.input.keywords, kws.OptKeywords): + logger.warning( + "Non-optimisation calculation performed - no change" + " to geometry" + ) + return calc.molecule.coordinates + + if calc.molecule.n_atoms == 1: + # Coordinate of a single atom will not change + return calc.molecule.coordinates + + coords: list[list[float]] = [] + + for i, line in enumerate(calc.output.file_lines): + if "Coordinates (Angstroms)" in line: + start_idx = i + 2 + elif "Standard Nuclear Orientation (Angstroms)" in line: + start_idx = i + 3 + else: + continue + + """e.g. + Coordinates (Angstroms) + ATOM X Y Z + 1 O 0.0003489977 -0.1403224128 0.0000000000 + 2 H -0.7524338562 0.4527672831 0.0000000000 + 3 H 0.7551329498 0.4500625364 0.0000000000 + Point Group: cs Number of degrees of freedom: 3 + """ + + end_idx = start_idx + calc.molecule.n_atoms + coords = [] + for cline in calc.output.file_lines[start_idx:end_idx]: + x, y, z = cline.split()[2:5] + coords.append([float(x), float(y), float(z)]) + + return Coordinates(coords, units="Å") + + def partial_charges_from(self, calc: "CalculationExecutor") -> List[float]: + raise NotImplementedInMethod + + def _energy_from(self, calc: "CalculationExecutor") -> PotentialEnergy: + """Get the total electronic energy from the calculation""" + + for line in reversed(calc.output.file_lines): + if "Total energy" in line: + try: + return PotentialEnergy(line.split()[-1], units="Ha") + + except (TypeError, ValueError, IndexError): + break + + raise CouldNotGetProperty("energy") + + def gradient_from(self, calc: "CalculationExecutor") -> Gradient: + """Gradient of the potential energy""" + + try: + gradients = self._raw_opt_gradient(calc) + + except CouldNotGetProperty: # Failed to get gradient from optimisation + gradients = self._raw_scf_grad(calc) + + return Gradient(gradients, units="Ha a0^-1").to("Ha Å^-1") + + def hessian_from( + self, calc: "autode.calculations.executors.CalculationExecutor" + ) -> Hessian: + """Extract the mass-weighted non projected Hessian matrix + NOTE: Required $rem vibman_print 4 $end in the input""" + assert calc.input.keywords is not None, "Must have keywords" + + hessian = self._extract_mass_weighted_hessian(calc) + atom_masses = self._extract_atomic_masses(calc) + + # Un-mass weight + mass_arr = np.repeat(atom_masses, repeats=3, axis=np.newaxis) + hessian *= np.sqrt(np.outer(mass_arr, mass_arr)) + + return Hessian( + hessian, + atoms=calc.molecule.atoms, + functional=calc.input.keywords.functional, + units="Ha a0^-2", + ).to("Ha Å^-2") + + @staticmethod + def _raw_opt_gradient(calc) -> list: + grad = [] + + for i, line in enumerate(calc.output.file_lines): + if "Cartesian Gradient" not in line: + continue + + """e.g. + + Cartesian Gradient (au) + ATOM X Y Z + 1 O 0.000005 -0.000002 0.000000 + 2 H 0.000017 0.000001 0.000000 + 3 H -0.000021 0.000001 0.000000 + """ + + start_idx = i + 2 + end_idx = start_idx + calc.molecule.n_atoms + + grad = [ + [float(val) for val in _l.split()[2:]] + for _l in calc.output.file_lines[start_idx:end_idx] + ] + + if len(grad) == 0: + raise CouldNotGetProperty("gradient") + + return grad + + @staticmethod + def _raw_scf_grad(calc): + grad = [] + n_grad_lines = (calc.molecule.n_atoms // 6 + 1) * 4 + + for i, line in enumerate(calc.output.file_lines): + if "Gradient of SCF Energy" not in line: + continue + + """e.g. + + ----------------------------------------------------------------- + Calculating analytic gradient of the SCF energy + Gradient of SCF Energy + 1 2 3 4 5 + 1 -0.0108562 -0.0095972 0.0087634 0.0032518 -0.0040093 + """ + + start_idx = i + 1 + end_idx = start_idx + n_grad_lines + lines_slice = calc.output.file_lines[start_idx:end_idx] + + grad = [] + + for j in range(len(lines_slice) // 4): + x_line = lines_slice[4 * j + 1] + y_line = lines_slice[4 * j + 2] + z_line = lines_slice[4 * j + 3] + + for k in range(1, len(x_line.split())): + grad.append( + [ + float(x_line.split()[k]), + float(y_line.split()[k]), + float(z_line.split()[k]), + ] + ) + + if len(grad) == 0: + raise CouldNotGetProperty("gradient") + + return grad + + @staticmethod + def _extract_atomic_masses(calc) -> np.ndarray: + masses = [] + for line in calc.output.file_lines: + if "Has Mass" in line: + # e.g. + # Atom 1 Element O Has Mass 15.99491 + + mass = float(line.split()[-1]) + masses.append(mass) + + # Only return the final n_atoms masses + return np.array(masses[-calc.molecule.n_atoms :]) + + @staticmethod + def _extract_mass_weighted_hessian(calc) -> np.ndarray: + """Extract the mass weighted Hessian as a 3Nx3N matrix (N = n_atoms)""" + + n_atoms = calc.molecule.n_atoms + lines = calc.output.file_lines + + hess = [] + + def correct_shape(_hess): + """Is the Hessian the correct shape? 3N x 3N""" + return len(_hess) == 3 * n_atoms and all( + len(row) == 3 * n_atoms for row in _hess + ) + + for i, line in enumerate(lines): + if "Mass-Weighted Hessian Matrix" not in line: + continue + + start_idx = i + 3 + end_idx = start_idx + 3 * n_atoms + + hess = [ + [float(val) for val in _l.split()] + for _l in lines[start_idx:end_idx] + ] + + while not correct_shape(hess): + try: + start_idx = end_idx + 2 + end_idx = start_idx + 3 * n_atoms + lines_slice = lines[start_idx:end_idx] + + if len(lines_slice) == 0: + raise AssertionError + + for j, _l in enumerate(lines_slice): + hess[j] += [float(val) for val in _l.split()] + + except (TypeError, ValueError, AssertionError): + raise CouldNotGetProperty("Hessian") + + if not correct_shape(hess): + raise CouldNotGetProperty("Hessian") + + return np.array(hess) + + @staticmethod + def _is_ts_opt(calc) -> bool: + """Is the calculation a QChem TS optimisation?""" + return any( + "jobtype" in word.lower() and "ts" in word.lower() + for word in calc.input.keywords + ) + + @staticmethod + def _keywords_contain(calc, string) -> bool: + return any(string in w.lower() for w in calc.input.keywords) + + class _InputFileWriter: + def __init__(self, filename): + self.file = open(filename, "w") + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.file.close() + + def write(self, string, end="\n") -> None: + print(string, file=self.file, end=end) + + def add_freq(self, calc) -> None: + """Add a frequency calculation""" + + freq_calc = calc.copy() + kwds = kws.HessianKeywords( + [ + kwd + for kwd in freq_calc.input.keywords + if "jobtype" not in kwd.lower() + ] + ) + freq_calc.input.keywords = kwds + + self.add_rem_block(freq_calc) + self.add_solvent_block(freq_calc) + + return None + + def add_calculation_seperator(self) -> None: + return self.write("\n@@@\n") + + def add_molecule_read(self) -> None: + return self.write("$molecule\n read\n$end") + + def add_constraints(self, calc) -> None: + """Add cartesian and distance constraints""" + + if not isinstance(calc.input.keywords, kws.OptKeywords): + # Constraints are only needed for optimisations + return None + + constraints = calc.molecule.constraints + + if calc.input.added_internals is None and not constraints.any: + return None + + self.write("$opt") + + if constraints.distance is not None: + self.write("CONSTRAINT") + + for (i, j), dist in constraints.distance.items(): + self.write(f'stre {i+1} {j+1} {dist.to("Å"):.5f}') + + self.write("ENDCONSTRAINT") + + if constraints.cartesian is not None: + self.write("FIXED") + + for i in constraints.cartesian: + self.write(f"{i+1} XYZ") # where i is an atom index + + self.write("ENDFIXED") + + if calc.input.added_internals is not None: + self.write("CONNECT") + + for i, j in calc.input.added_internals: + self.write(f"{i+1} 1 {j+1}") + + self.write("ENDCONNECT") + + self.write("$end\n") + return None + + def add_solvent_block(self, calc) -> None: + """Add the solvent section, appropriate for an SMx solvent model""" + if calc.molecule.solvent is None: + # calculation is in the gas phase + return None + + self.write( + "$smx\n" f"solvent {calc.molecule.solvent.qchem}\n" f"$end\n" + ) + + return None + + def add_molecule_block(self, molecule) -> None: + """Print molecular cartesian coordinates to the input file""" + + self.write("$molecule\n" f"{molecule.charge} {molecule.mult}") + + for atom in molecule.atoms: + x, y, z = atom.coord + self.write(f"{atom.label:<3} {x:^12.8f} {y:^12.8f} {z:^12.8f}") + + self.write("$end\n") + return None + + def add_rem_block(self, calc) -> None: + """Add the calculation specification in a $rem block""" + keywords = calc.input.keywords + + if any("$" in word.lower() for word in keywords): + raise NotImplementedError( + "Cannot add $rem block - already " f"present in {keywords}" + ) + + self.write("$rem") + + if calc.molecule.n_atoms > 1: # Defaults to a single point + self._write_job_type(keywords) + + self._write_keywords(keywords, molecule=calc.molecule) + + if not isinstance(calc.input.keywords, kws.SinglePointKeywords): + self.write("symmetry False") + self.write("sym_ignore True") + + total_memory_in_mb = int(Config.max_core.to("MB") * calc.n_cores) + self.write(f"mem_total {total_memory_in_mb}") + + self.write("$end\n") + + return None + + def _write_ecp(self, ecp_kwd, molecule) -> None: + """Write the effective core potential (ECP) block, if required""" + + ecp_elems = set( + atom.label + for atom in molecule.atoms + if atom.atomic_number >= ecp_kwd.min_atomic_number + ) + + if len(ecp_elems) > 0: + logger.info(f"Writing ECP block for atoms {ecp_elems}") + self.write(f"ecp {ecp_kwd.qchem}") + + return None + + def _write_keywords(self, keywords, molecule) -> None: + for word in keywords: + if isinstance(word, kws.BasisSet): + self.write(f"basis {word.qchem}") + + elif isinstance(word, kws.Functional): + self.write(f"method {word.qchem}") + + elif isinstance(word, kws.DispersionCorrection): + self.write(f"dft_d {word.qchem}") + + elif isinstance(word, kws.MaxOptCycles): + self.write(f"geom_opt_max_cycles {word}") + + elif isinstance(word, kws.ECP): + self._write_ecp(word, molecule=molecule) + + elif isinstance(word, kws.ImplicitSolventType): + if word.lower() != "smd": + err = f"Only SMD solvent is supported. Had: {word}" + raise UnsupportedCalculationInput(err) + + self.write("solvent_method smd") + + elif "jobtype" in word.lower(): + if molecule.n_atoms == 1 and "opt" in word.lower(): + logger.warning("Cannot optimise a single atom") + + elif " ts" in word.lower(): + self.write(word) + # A completed Hessian calculation must be present + self.write("geom_opt_hessian read") + + else: + self.write(word) + + else: + self.write(word) + + return None + + def _write_job_type(self, keywords) -> None: + if any("jobtype" in word.lower() for word in keywords): + logger.info("QChem *jobtype* already defined - not appending") + + elif isinstance(keywords, kws.OptKeywords): + self.write("jobtype opt") + + elif isinstance(keywords, kws.HessianKeywords): + self.write("jobtype freq") + + elif isinstance(keywords, kws.GradientKeywords): + self.write("jobtype force") + + if isinstance(keywords, kws.OptKeywords) or isinstance( + keywords, kws.HessianKeywords + ): + # Print the Hessian + self.write("geom_opt_print 4\n" "vibman_print 4") + + return None + + +class QChemOptimiser(ExternalOptimiser): + def __init__(self, output_lines: List[str]): + self._lines = output_lines + + @property + def converged(self) -> bool: + return any("OPTIMIZATION CONVERGED" in line for line in self._lines) + + @property + def last_energy_change(self) -> "PotentialEnergy": + raise NotImplementedError diff --git a/autodE/source/autode/wrappers/XTB.py b/autodE/source/autode/wrappers/XTB.py new file mode 100644 index 0000000000000000000000000000000000000000..a685f024870e08c93bdd5a76bd6450505e00504d --- /dev/null +++ b/autodE/source/autode/wrappers/XTB.py @@ -0,0 +1,418 @@ +import os +import shutil +import numpy as np +import autode.wrappers.methods + +from typing import TYPE_CHECKING + +from autode.values import Coordinates, Gradient, PotentialEnergy, Temperature +from autode.utils import run_external +from autode.wrappers.keywords import OptKeywords, GradientKeywords +from autode.config import Config +from autode.opt.optimisers.base import ExternalOptimiser +from autode.exceptions import AtomsNotFound, CouldNotGetProperty +from autode.utils import work_in_tmp_dir, run_in_tmp_environment +from autode.log import logger + +if TYPE_CHECKING: + from autode.calculations.executors import CalculationExecutor + from autode.opt.optimisers.base import BaseOptimiser + + +class XTB(autode.wrappers.methods.ExternalMethodOEG): + def __init__(self): + super().__init__( + executable_name="xtb", + path=Config.XTB.path, + keywords_set=Config.XTB.keywords, + implicit_solvation_type=Config.XTB.implicit_solvation_type, + doi_list=["10.1002/wcms.1493"], + ) + + self.force_constant = Config.XTB.force_constant + self.electronic_temp = Config.XTB.electronic_temp + self.gfn_version = Config.XTB.gfn_version + + def __repr__(self): + return f"XTB(available = {self.is_available})" + + def print_distance_constraints(self, inp_file, molecule): + """Add distance constraints to the input file""" + + if molecule.constraints.distance is None: + return None + + for (i, j), dist in molecule.constraints.distance.items(): + # XTB counts from 1 so increment atom ids by 1 + print( + f"$constrain\n" + f"force constant={self.force_constant}\n" + f"distance:{i+1}, {j+1}, {dist:.4f}\n$", + file=inp_file, + ) + return None + + def print_cartesian_constraints(self, inp_file, molecule): + """Add cartesian constraints to an xtb input file""" + + if molecule.constraints.cartesian is None: + return None + + atom_idxs = list( + sorted(int(i) + 1 for i in molecule.constraints.cartesian) + ) + list_of_ranges = [] + + for atom_idx in atom_idxs: + last_range = ( + list_of_ranges[-1] if len(list_of_ranges) > 0 else None + ) + if last_range is not None and atom_idx - 1 == last_range[-1]: + last_range.append(atom_idx) + else: + list_of_ranges.append([atom_idx]) + + list_of_ranges_str = [ + f"{idxs[0]}-{idxs[-1]}" if len(idxs) > 1 else str(idxs[0]) + for idxs in list_of_ranges + ] + print( + f"$constrain\n" + f"force constant={self.force_constant}\n" + f'atoms: {",".join(list_of_ranges_str)}\n' + f"$", + file=inp_file, + ) + return None + + @staticmethod + def print_point_charge_file(calc: "CalculationExecutor"): + """Generate a point charge file""" + + if calc.input.point_charges is None: + return None + + with open(f"{calc.name}_xtb.pc", "w") as pc_file: + print(len(calc.input.point_charges), file=pc_file) + + for point_charge in calc.input.point_charges: + x, y, z = point_charge.coord + charge = point_charge.charge + print( + f"{charge:^12.8f} {x:^12.8f} {y:^12.8f} {z:^12.8f}", + file=pc_file, + ) + + calc.input.additional_filenames.append(f"{calc.name}_xtb.pc") + return None + + def print_xcontrol_file(self, calc: "CalculationExecutor", molecule): + """Print an XTB input file with constraints and point charges""" + + # do not print constraints if not optimisation + if not isinstance(calc.input.keywords, OptKeywords) and ( + calc.input.point_charges is None + ): + return + + xcontrol_filename = f"xcontrol_{calc.name}" + with open(xcontrol_filename, "w") as xcontrol_file: + self.print_distance_constraints(xcontrol_file, molecule) + self.print_cartesian_constraints(xcontrol_file, molecule) + + if calc.input.point_charges is not None: + self.print_point_charge_file(calc) + print( + f"$embedding\n" + f"input={calc.name}_xtb.pc\n" + f"input=orca\n" + f"$end", + file=xcontrol_file, + ) + + calc.input.additional_filenames.append(xcontrol_filename) + return + + def generate_input_for(self, calc: "CalculationExecutor"): + molecule = calc.molecule + calc.molecule.print_xyz_file(filename=calc.input.filename) + + if molecule.constraints.any or calc.input.point_charges: + self.print_xcontrol_file(calc, molecule) + + return None + + @staticmethod + def input_filename_for(calc: "CalculationExecutor"): + return f"{calc.name}.xyz" + + @staticmethod + def output_filename_for(calc: "CalculationExecutor"): + return f"{calc.name}.out" + + def version_in(self, calc: "CalculationExecutor"): + """Get the XTB version from the output file""" + + for line in calc.output.file_lines: + if "xtb version" in line and len(line.split()) >= 4: + # e.g. * xtb version 6.2.3 (830e466) compiled by .... + return line.split()[3] + + logger.warning("Could not find the XTB version in the output file") + return "???" + + @staticmethod + def _remove_xtbopt_xyz_file() -> None: + if os.path.exists("xtbopt.xyz"): + os.remove("xtbopt.xyz") + + return None + + @property + def _electronic_temp_str(self) -> str: + assert self.electronic_temp is not None + if isinstance(self.electronic_temp, Temperature): + electronic_temp = self.electronic_temp.to("K") + else: + logger.warning("Assuming XTB electronic_temp is in K") + electronic_temp = self.electronic_temp + + return str(electronic_temp) + + def execute(self, calc: "CalculationExecutor"): + """Execute an XTB calculation using the runtime flags""" + # XTB calculation keywords must be a class + + flags = ["--chrg", str(calc.molecule.charge)] + flags += ["--uhf", str(calc.molecule.mult - 1)] + + if self.electronic_temp is not None: + flags += ["--etemp", self._electronic_temp_str] + if self.gfn_version is not None: + flags += ["--gfn", str(self.gfn_version)] + + if isinstance(calc.input.keywords, OptKeywords): + if calc.input.keywords.max_opt_cycles is not None: + logger.warning("Switching off optimisation cycle limit") + calc.input.keywords.max_opt_cycles = None + + if len(calc.input.keywords) != 0: + flags += list(calc.input.keywords) + + elif isinstance(calc.input.keywords, OptKeywords): + flags.append("--opt") + + elif isinstance(calc.input.keywords, GradientKeywords): + flags.append("--grad") + + if calc.molecule.solvent is not None: + assert calc.molecule.solvent.xtb is not None + flags += ["--gbsa", calc.molecule.solvent.xtb] + + if len(calc.input.additional_filenames) > 0: + # XTB allows for an additional xcontrol file, which should be the + # last file in the list + flags += ["--input", calc.input.additional_filenames[-1]] + + @work_in_tmp_dir( + filenames_to_copy=calc.input.filenames, + kept_file_exts=(".xyz", ".out", ".pc", ".grad"), + use_ll_tmp=True, + ) + @run_in_tmp_environment( + OMP_NUM_THREADS=calc.n_cores, GFORTRAN_UNBUFFERED_ALL=1 + ) + def execute_xtb(): + logger.info(f'Running XTB with: {" ".join(flags)}') + run_external( + params=[calc.method.path, calc.input.filename] + flags, + output_filename=calc.output.filename, + ) + + if os.path.exists("gradient"): + shutil.move("gradient", f"{calc.name}_OLD.grad") + + self._remove_xtbopt_xyz_file() + + execute_xtb() + return None + + def terminated_normally_in(self, calc): + for n_line, line in enumerate(reversed(calc.output.file_lines)): + if "ERROR" in line: + return False + if n_line > 20: + # With xtb we will search for there being no '#ERROR!' in the + # last few lines + return True + + return False + + def _energy_from(self, calc: "CalculationExecutor") -> PotentialEnergy: + for line in reversed(calc.output.file_lines): + if "total E" in line: + return PotentialEnergy(line.split()[-1], units="Ha") + if "TOTAL ENERGY" in line: + return PotentialEnergy(line.split()[-3], units="Ha") + + raise CouldNotGetProperty(name="energy") + + @staticmethod + def converged_line_in_output(calc): + for line in reversed(calc.output.file_lines): + if "GEOMETRY OPTIMIZATION CONVERGED" in line: + return True + + return False + + def optimiser_from(self, calc: "CalculationExecutor") -> "BaseOptimiser": + return XTBOptimiser(converged=self.converged_line_in_output(calc)) + + @staticmethod + def _get_final_coords_6_2_above(calc: "CalculationExecutor"): + """ + e.g. + + ================ + final structure: + ================ + 5 + xtb: 6.2.3 (830e466) + Cl 1.62694523673790 0.09780349799138 -0.02455489507427 + C -0.15839164427314 -0.00942638308615 0.00237760557913 + H -0.46867957388620 -0.59222865914178 -0.85786049981721 + H -0.44751262498645 -0.49575975568264 0.92748366742968 + H -0.55236139359212 0.99971129991918 -0.04744587811734 + """ + matrix = [] + + for i, line in enumerate(calc.output.file_lines): + if "final structure" in line: + n_atoms = int(calc.output.file_lines[i + 2].split()[0]) + + for xyz_line in calc.output.file_lines[ + i + 4 : i + 4 + n_atoms + ]: + _, x, y, z = xyz_line.split() + matrix.append([float(x), float(y), float(z)]) + break + + return Coordinates(matrix, units="Å") + + @staticmethod + def _get_final_coords_old(calc: "CalculationExecutor"): + """ + e.g. + + ================ + final structure: + ================ + $coord + 2.52072290250473 -0.04782551206377 -0.50388676977877 C + . . . . + """ + matrix = [] + geom_section = False + + for line in calc.output.file_lines: + if "$coord" in line: + geom_section = True + + if "$end" in line and geom_section: + geom_section = False + + if len(line.split()) == 4 and geom_section: + x, y, z, _ = line.split() + matrix.append([float(x), float(y), float(z)]) + + return Coordinates(matrix, units="a0").to("Å") + + def coordinates_from(self, calc: "CalculationExecutor"): + for i, line in enumerate(calc.output.file_lines): + # XTB 6.2.x have a slightly different way of printing the atoms + if ( + "xtb version" in line + or "Version" in line + and len(line.split()) >= 4 + ): + if line.split()[3] == "6.2.2" or "6.1" in line.split()[2]: + return self._get_final_coords_old(calc) + + else: + return self._get_final_coords_6_2_above(calc) + + # Version is not recognised if we're 50 lines into the output file + # - try and use the old version + if i > 50: + return self._get_final_coords_old(calc) + + raise AtomsNotFound( + "Failed to find any coordinates in XTB " "output file" + ) + + def partial_charges_from(self, calc: "CalculationExecutor"): + charges_sect = False + charges = [] + for line in calc.output.file_lines: + if "Mol." in line: + charges_sect = False + if charges_sect and len(line.split()) == 7: + charges.append(float(line.split()[4])) + if "covCN" in line: + charges_sect = True + return charges + + def gradient_from(self, calc: "CalculationExecutor"): + raw = [] + + if os.path.exists(f"{calc.name}_xtb.grad"): + grad_file_name = f"{calc.name}_xtb.grad" + with open(grad_file_name, "r") as grad_file: + for line in grad_file: + x, y, z = line.split() + raw.append(np.array([float(x), float(y), float(z)])) + + elif os.path.exists(f"{calc.name}_OLD.grad"): + with open(f"{calc.name}_OLD.grad", "r") as grad_file: + for i, line in enumerate(grad_file): + if i > 1 and len(line.split()) == 3: + x, y, z = line.split() + vec = [ + float(x.replace("D", "E")), + float(y.replace("D", "E")), + float(z.replace("D", "E")), + ] + + raw.append(np.array(vec)) + + os.remove(f"{calc.name}_OLD.grad") + + with open(f"{calc.name}_xtb.grad", "w") as new_grad_file: + [ + print( + "{:^12.8f} {:^12.8f} {:^12.8f}".format(*line), + file=new_grad_file, + ) + for line in raw + ] + + if len(raw) == 0: + raise CouldNotGetProperty(name="gradient") + + return Gradient(raw, units="Ha a0^-1").to("Ha Å^-1") + + +class XTBOptimiser(ExternalOptimiser): + def __init__(self, converged: bool): + self._converged = converged + + @property + def converged(self) -> bool: + return self._converged + + @property + def last_energy_change(self) -> "PotentialEnergy": + raise NotImplementedError + + +xtb = XTB() diff --git a/autodE/source/autode/wrappers/__init__.py b/autodE/source/autode/wrappers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/autodE/source/autode/wrappers/keywords/__init__.py b/autodE/source/autode/wrappers/keywords/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ed8d0ce83e6481e8b74fc44e290e8c4b00d7171a --- /dev/null +++ b/autodE/source/autode/wrappers/keywords/__init__.py @@ -0,0 +1,66 @@ +from autode.wrappers.keywords.keywords import ( + KeywordsSet, + Keywords, + Keyword, + OptKeywords, + OptTSKeywords, + HessianKeywords, + GradientKeywords, + SinglePointKeywords, + BasisSet, + DispersionCorrection, + Functional, + ImplicitSolventType, + RI, + WFMethod, + ECP, + MaxOptCycles, +) +from autode.wrappers.keywords.basis_sets import ( + def2svp, + def2tzvp, + def2ecp, + def2tzecp, +) +from autode.wrappers.keywords.dispersion import d3bj +from autode.wrappers.keywords.functionals import pbe0, pbe +from autode.wrappers.keywords.implicit_solvent_types import ( + smd, + cpcm, + cosmo, + gbsa, +) +from autode.wrappers.keywords.ri import rijcosx +from autode.wrappers.keywords.wf import hf + +__all__ = [ + "def2svp", + "def2tzvp", + "def2ecp", + "def2tzecp", + "d3bj", + "pbe0", + "pbe", + "cosmo", + "gbsa", + "cpcm", + "smd", + "rijcosx", + "hf", + "KeywordsSet", + "Keywords", + "Keyword", + "OptKeywords", + "OptTSKeywords", + "HessianKeywords", + "GradientKeywords", + "SinglePointKeywords", + "BasisSet", + "DispersionCorrection", + "Functional", + "ImplicitSolventType", + "RI", + "WFMethod", + "ECP", + "MaxOptCycles", +] diff --git a/autodE/source/autode/wrappers/keywords/basis_sets.py b/autodE/source/autode/wrappers/keywords/basis_sets.py new file mode 100644 index 0000000000000000000000000000000000000000..322643d2c6a421606756370cb3bb90dee05c8821 --- /dev/null +++ b/autodE/source/autode/wrappers/keywords/basis_sets.py @@ -0,0 +1,46 @@ +from autode.wrappers.keywords.keywords import BasisSet, ECP + +def2svp = BasisSet( + name="def2-SVP", + doi="10.1039/B508541A", + orca="def2-SVP", + g09="Def2SVP", + nwchem="Def2-SVP", + qchem="def2-SVP", +) + +def2tzvp = BasisSet( + name="def2-TZVP", + doi="10.1039/B508541A", + orca="def2-TZVP", + g09="Def2TZVP", + nwchem="Def2-TZVP", + qchem="def2-TZVP", +) + + +def2ecp = ECP( + name="def2-ECP", + doi_list=[ + "Ce-Yb: 10.1063/1.456066", + "Y-Cd, Hf-Hg: 10.1007/BF01114537", + "Te-Xe, In-Sb, Ti-Bi: 10.1063/1.1305880", + "Po-Rn: 10.1063/1.1622924", + "Rb, Cs: 10.1016/0009-2614(96)00382-X", + "Sr, Ba: 10.1063/1.459993", + "La: 10.1007/BF00528565", + "Lu: 10.1063/1.1406535", + ], + orca="", # def2-ECP is applied by default + nwchem="def2-ecp", + qchem="def2-ecp", + min_atomic_number=37, +) # applies to Rb and heavier + + +def2tzecp = ECP( + name="def2TZVP", # Gaussian uses a combined definition + g09="def2TZVP", + g16="def2TZVP", + min_atomic_number=37, +) diff --git a/autodE/source/autode/wrappers/keywords/dispersion.py b/autodE/source/autode/wrappers/keywords/dispersion.py new file mode 100644 index 0000000000000000000000000000000000000000..571ac2c0cd32c16f431c84c6ae75596acb1e3af3 --- /dev/null +++ b/autodE/source/autode/wrappers/keywords/dispersion.py @@ -0,0 +1,9 @@ +from autode.wrappers.keywords.keywords import DispersionCorrection + +d3bj = DispersionCorrection( + name="d3bj", + doi_list=["10.1063/1.3382344", "10.1002/jcc.21759"], + g09="GD3BJ", + orca="D3BJ", + qchem="D3_BJ", +) diff --git a/autodE/source/autode/wrappers/keywords/functionals.py b/autodE/source/autode/wrappers/keywords/functionals.py new file mode 100644 index 0000000000000000000000000000000000000000..17ba43ad401f31cb6b4197e5ca77d708c53cddab --- /dev/null +++ b/autodE/source/autode/wrappers/keywords/functionals.py @@ -0,0 +1,26 @@ +""" +Functional instances. Frequency scale factors have been obtained from +https://cccbdb.nist.gov/vibscalejust.asp and the basis set dependence assumed +to be negligible at least double zetas (i.e. >6-31G) +""" +from autode.wrappers.keywords.keywords import Functional + +pbe0 = Functional( + name="pbe0", + doi_list=["10.1063/1.478522", "10.1103/PhysRevLett.77.3865"], + orca="PBE0", + g09="PBE1PBE", + nwchem="pbe0", + qchem="pbe0", + freq_scale_factor=0.96, +) + +pbe = Functional( + name="pbe", + doi_list=["10.1103/PhysRevLett.77.3865"], + orca="PBE", + g09="PBEPBE", + nwchem="xpbe96 cpbe96", + qchem="pbe", + freq_scale_factor=0.99, +) diff --git a/autodE/source/autode/wrappers/keywords/implicit_solvent_types.py b/autodE/source/autode/wrappers/keywords/implicit_solvent_types.py new file mode 100644 index 0000000000000000000000000000000000000000..f4f67a41dec3cb1626e49a30efdd6be6a1042ebb --- /dev/null +++ b/autodE/source/autode/wrappers/keywords/implicit_solvent_types.py @@ -0,0 +1,8 @@ +from autode.wrappers.keywords.keywords import ImplicitSolventType + +cpcm = ImplicitSolventType("cpcm", doi="10.1021/jp9716997") +smd = ImplicitSolventType("smd", doi="10.1021/jp810292n") +cosmo = ImplicitSolventType("cosmo", doi="10.1039%2FP29930000799") +gbsa = ImplicitSolventType( + "gbsa", doi_list=["10.1007/BF01881023", "10.1016/0009-2614(67)85048-6"] +) diff --git a/autodE/source/autode/wrappers/keywords/keywords.py b/autodE/source/autode/wrappers/keywords/keywords.py new file mode 100644 index 0000000000000000000000000000000000000000..c67b12099a802633f3dad3df207c043573419467 --- /dev/null +++ b/autodE/source/autode/wrappers/keywords/keywords.py @@ -0,0 +1,685 @@ +from typing import Union, Optional, Sequence, List, Type, Iterator, TypeVar +from copy import deepcopy +from abc import ABC, abstractmethod +from autode.log import logger + +TypeKeywords = TypeVar("TypeKeywords", bound="Keywords") + + +class KeywordsSet: + def __init__( + self, + low_opt: Optional["_KEYWORDS_TYPE"] = None, + grad: Optional["_KEYWORDS_TYPE"] = None, + low_sp: Optional["_KEYWORDS_TYPE"] = None, + opt: Optional["_KEYWORDS_TYPE"] = None, + opt_ts: Optional["_KEYWORDS_TYPE"] = None, + hess: Optional["_KEYWORDS_TYPE"] = None, + sp: Optional["_KEYWORDS_TYPE"] = None, + ecp: Optional["ECP"] = None, + ): + """ + Keywords used to specify the type and method used in electronic + structure theory calculations. The input file for a single point + calculation will look something like:: + + ------------------------------------------------------------------- + autode.KeywordsSet.keywords[0] ... + + + . + . + coordinates + . + . + + ------------------------------------------------------------------- + + ----------------------------------------------------------------------- + Arguments: + + low_opt: List of keywords for a low level optimisation + + grad: List of keywords for a gradient calculation + + low_sp: Low-level single point + + opt: List of keywords for a optimisation + + opt_ts: List of keywords for a transition state optimisation + + hess: List of keywords for a hessian calculation + + sp: List of keywords for a single point calculation + + ecp: Effective core potential to use for atoms heavier than + ecp.min_atomic_number, if not None + + optts_block: String as extra input for a TS optimisation + """ + + self._low_opt: OptKeywords = OptKeywords(low_opt) + self._opt: OptKeywords = OptKeywords(opt) + self._opt_ts: OptTSKeywords = OptTSKeywords(opt_ts) + + self._grad: GradientKeywords = GradientKeywords(grad) + self._hess: HessianKeywords = HessianKeywords(hess) + + self._low_sp: SinglePointKeywords = SinglePointKeywords(low_sp) + self._sp: SinglePointKeywords = SinglePointKeywords(sp) + + if ecp is not None: + self.set_ecp(ecp) + + def __repr__(self): + str_methods = ",\n".join(str(c) for c in self._list if c is not None) + return f"KeywordsSet({str_methods})" + + def __getitem__(self, item: int) -> "Keywords": + return self._list[item] + + def __iter__(self) -> Iterator["Keywords"]: + yield from self._list + + def __eq__(self, other: object) -> bool: + """Equality of two keyword sets""" + return isinstance(other, KeywordsSet) and self._list == other._list + + @property + def low_opt(self) -> "OptKeywords": + return self._low_opt + + @low_opt.setter + def low_opt(self, value: Optional[Sequence[str]]): + self._low_opt = OptKeywords(value) + + @property + def opt(self) -> "OptKeywords": + return self._opt + + @opt.setter + def opt(self, value: Optional[Sequence[str]]): + self._opt = OptKeywords(value) + + @property + def opt_ts(self) -> "OptTSKeywords": + return self._opt_ts + + @opt_ts.setter + def opt_ts(self, value: Optional[Sequence[str]]): + self._opt_ts = OptTSKeywords(value) + + @property + def grad(self) -> "GradientKeywords": + return self._grad + + @grad.setter + def grad(self, value: Optional[Sequence[str]]): + self._grad = GradientKeywords(value) + + @property + def hess(self) -> "HessianKeywords": + return self._hess + + @hess.setter + def hess(self, value: Optional[Sequence[str]]): + self._hess = HessianKeywords(value) + + @property + def low_sp(self) -> "SinglePointKeywords": + return self._low_sp + + @low_sp.setter + def low_sp(self, value: Optional[Sequence[str]]): + self._low_sp = SinglePointKeywords(value) + + @property + def sp(self) -> "SinglePointKeywords": + return self._sp + + @sp.setter + def sp(self, value: Optional[Sequence[str]]): + self._sp = SinglePointKeywords(value) + + @property + def _list(self) -> List["Keywords"]: + """List of all the keywords in this set""" + return [ + self._low_opt, + self._opt, + self._opt_ts, + self._grad, + self._hess, + self._sp, + self._low_sp, + ] + + def set_opt_functional(self, functional: Union["Functional", str]): + """Set the functional for all optimisation and gradient calculations""" + for attr in ("low_opt", "opt", "opt_ts", "grad", "hess"): + getattr(self, attr).functional = functional + + return None + + def set_opt_basis_set(self, basis_set: Union["BasisSet", str]): + """Set the basis set for all optimisation and gradient calculations""" + for attr in ("low_opt", "opt", "opt_ts", "grad", "hess"): + getattr(self, attr).basis_set = basis_set + + return None + + def set_functional(self, functional: Union["Functional", str]): + """Set the functional for all calculation types""" + for keywords in self: + keywords.functional = functional + + return None + + def set_dispersion(self, dispersion: Union["DispersionCorrection", str]): + """Set the dispersion correction for all calculation types""" + for keywords in self: + keywords.dispersion = dispersion + + return None + + def set_ecp(self, ecp: Union["ECP", str]): + """Set the effective core potential for all calculation types""" + for keywords in self: + keywords.ecp = ecp + + return None + + def copy(self) -> "KeywordsSet": + return deepcopy(self) + + +class Keywords(ABC): + def __init__( + self, keyword_list: Union["_KEYWORDS_TYPE", str, None] = None + ): + """ + List of keywords used in an electronic structure calculation + + ----------------------------------------------------------------------- + Arguments: + keyword_list: Keywords + """ + + self._list: List[Union[Keyword, str]] = [] + + if isinstance(keyword_list, str): + self._list = [keyword_list] + elif keyword_list is not None: + self._list = list(keyword_list) + + def __str__(self): + return " ".join([repr(kw) for kw in self._list]) + + def __eq__(self, other: object) -> bool: + """Equality of these keywords to another kind""" + return isinstance(other, self.__class__) and set(self._list) == set( + other._list + ) + + def __add__(self, other: object): + """Add some keywords to these""" + + if isinstance(other, Keywords): + return self.__class__(self._list + other._list) + + elif isinstance(other, list): + return self.__class__(self._list + other) + + else: + raise ValueError( + f"Cannot add {other} to the keywords. Must be a " + f"list or a Keywords object" + ) + + @abstractmethod + def __repr__(self): + """Representation of these keywords""" + + def _get_keyword( + self, keyword_type: Type["Keyword"] + ) -> Optional["Keyword"]: + """Get a keyword given a type""" + + for keyword in self._list: + if isinstance(keyword, keyword_type): + return keyword + + return None + + def _set_keyword( + self, + keyword: Union["Keyword", str, None], + keyword_type: Type["Keyword"], + ): + """Set a keyword. A keyword of the same type must exist""" + if type(keyword) is str: + keyword = keyword_type(name=keyword) + + assert type(keyword) is keyword_type or keyword is None + + for i, keyword_in_list in enumerate(self._list): + if isinstance(keyword_in_list, keyword_type): + if keyword is None: + del self._list[i] + else: + self._list[i] = keyword + return + + # Cannot have both wavefunction and DFT methoda + if ( + isinstance(keyword_in_list, WFMethod) + and keyword_type == Functional + ) or ( + isinstance(keyword_in_list, Functional) + and keyword_type == WFMethod + ): + raise ValueError( + "Could not set a functional with a " + "WF method present, or vice-versa " + ) + + if keyword is None: # don't append None to list + return + + # This keyword does not appear in the list, so add it + self.append(keyword) + return None + + def tolist(self) -> List: + return self._list + + @property + def ecp(self): + """Get the effective core potential used""" + return self._get_keyword(ECP) + + @ecp.setter + def ecp(self, ecp: Union["ECP", str]): + """Set the functional in a set of keywords""" + self._set_keyword(ecp, keyword_type=ECP) + + @property + def functional(self): + """Get the functional in this set of keywords""" + return self._get_keyword(Functional) + + @functional.setter + def functional(self, functional: Union["Functional", str]): + """Set the functional in a set of keywords""" + self._set_keyword(functional, keyword_type=Functional) + + @property + def basis_set(self): + """Get the functional in this set of keywords""" + return self._get_keyword(BasisSet) + + @basis_set.setter + def basis_set(self, basis_set: Union["BasisSet", str]): + """Set the functional in a set of keywords""" + self._set_keyword(basis_set, keyword_type=BasisSet) + + @property + def dispersion(self): + """Get the dispersion keyword in this set of keywords""" + return self._get_keyword(DispersionCorrection) + + @dispersion.setter + def dispersion(self, dispersion: Union["DispersionCorrection", str]): + """Set the dispersion correction in a set of keywords""" + self._set_keyword(dispersion, keyword_type=DispersionCorrection) + + @property + def wf_method(self): + """Get the wavefunction method in this set of keywords""" + return self._get_keyword(WFMethod) + + @wf_method.setter + def wf_method(self, method: Union["WFMethod", str]): + self._set_keyword(method, keyword_type=WFMethod) + + @property + def method_string(self) -> str: + """Generate a string with refs (dois) for this method e.g. PBE0-D3BJ""" + string = "" + + func = self.functional + if func is not None: + string += f"{func.upper()}({func.doi_str})" + + disp = self.dispersion + if disp is not None: + string += f"-{disp.upper()}({disp.doi_str})" + + wf = self.wf_method + if wf is not None: + string += f"{str(wf)}({wf.doi_str})" + + ri = self._get_keyword(keyword_type=RI) + if ri is not None: + string += f"({ri.upper()}, {ri.doi_str})" + + if len(string) == 0: + logger.warning("Unknown method") + string = "???" + + return string + + @property + def bstring(self) -> str: + """Brief string without dois of the method e.g. PBE0-D3BJ/def2-SVP""" + + string = "" + + if self.functional is not None: + string += self.functional.upper() + + if self.wf_method is not None: + string += f"-{self.wf_method.upper()}" + + if self.dispersion is not None: + string += f"-{self.dispersion.upper()}" + + if self.basis_set is not None: + string += f"/{self.basis_set.name}" + + return string + + def contain_any_of(self, *words: str) -> bool: + """ + Do these keywords contain any of a set of other words? Not case + sensitive. + + ----------------------------------------------------------------------- + Arguments: + *words: Words that may be present in these keywords + + Returns: + (bool): + """ + kwds = set(w.lower() for w in self) + + return not kwds.isdisjoint(w.lower() for w in words) + + def copy(self) -> TypeKeywords: # type: ignore + return deepcopy(self) # type: ignore + + def append(self, item: Union["Keyword", str]) -> None: + assert type(item) is str or isinstance(item, Keyword) + + # Don't re-add a keyword that is already there + if any(kw.lower() == item.lower() for kw in self._list): + return + + self._list.append(item) + + def remove(self, item: "Keyword") -> None: + self._list.remove(item) + + def __getitem__(self, item: int) -> Union["Keyword", str]: + return self._list[item] + + def __setitem__(self, key: int, value: Union["Keyword", str]) -> None: + self._list[key] = value + + def __len__(self) -> int: + return len(self._list) + + def __iter__(self) -> Iterator: + return iter(self._list) + + +class OptKeywords(Keywords): + @property + def max_opt_cycles(self): + """ + Maximum number of optimisation cycles + + Returns: + (autode.wrappers.keywords.MaxOptCycles): + """ + return self._get_keyword(MaxOptCycles) + + @max_opt_cycles.setter + def max_opt_cycles(self, value: Union[int, "MaxOptCycles", None]): + """Set the maximum number of optimisation cycles""" + if value is None: + self._set_keyword(None, MaxOptCycles) + return + + if int(value) <= 0: + raise ValueError("Must have a positive number of opt cycles") + + self._set_keyword(MaxOptCycles(int(value)), MaxOptCycles) + + def __repr__(self): + return f"OptKeywords({self.__str__()})" + + +class OptTSKeywords(OptKeywords): + """Transition state optimisation keywords""" + + +class HessianKeywords(Keywords): + def __repr__(self): + return f"HessKeywords({self.__str__()})" + + +class GradientKeywords(Keywords): + def __repr__(self): + return f"GradKeywords({self.__str__()})" + + +class SinglePointKeywords(Keywords): + def __repr__(self): + return f"SPKeywords({self.__str__()})" + + +class Keyword(ABC): + def __init__( + self, name: str, doi_list: Optional[List[str]] = None, **kwargs + ): + """ + A keyword for an electronic structure theory method e.g. basis set or + functional, with possibly a an associated reference or set of + references. + + e.g. + keyword = Keyword(name='pbe') + keyword = Keyword(name='pbe', g09='xpbe96 cpbe96') + + --------------------------------------------------------------------- + Arguments: + name: (str) Name of the keyword/method + doi: (str) Digital object identifier for the method's paper + + Keyword Arguments: + kwargs: (str) Keyword in a particular electronic structure theory + package e.g. Keyword(..., orca='PBE0') for a + functional + """ + self.name = name + + self.g09: Optional[str] = None + self.g16: Optional[str] = None + self.qchem: Optional[str] = None + self.orca: Optional[str] = None + self.xtb: Optional[str] = None + self.nwchem: Optional[str] = None + + self.doi_list = [] + if "doi" in kwargs and kwargs["doi"] is not None: + self.doi_list.append(kwargs.pop("doi")) + + if doi_list is not None: + self.doi_list += doi_list + + # Update the attributes with any keyword arguments + self.__dict__.update(kwargs) + + # Gaussian 09 and Gaussian 16 keywords are the same + if "g09" in kwargs.keys(): + self.g16 = kwargs["g09"] + + @abstractmethod + def __repr__(self): + """Representation of this keyword""" + + def __eq__(self, other): + return str(self) == str(other) + + def __str__(self): + return self.name + + def __hash__(self): + """Unique hash of this object""" + return hash(repr(self)) + + def lower(self): + return self.name.lower() + + def upper(self): + return self.name.upper() + + @property + def doi_str(self): + return " ".join(self.doi_list) + + @property + def has_only_name(self): + """ + Determine if only a name has been set, in which case it will + be printed verbatim into an input file, otherwise needs keyword.method + to be set, where method is e.g. orca + """ + excl = ("name", "doi_list", "doi", "freq_scale_factor") + return all( + getattr(self, a) is None for a in self.__dict__ if a not in excl + ) + + +class BasisSet(Keyword): + """Basis set for a QM method""" + + def __repr__(self): + return f"BasisSet({self.name})" + + +class DispersionCorrection(Keyword): + """Functional for a DFT method""" + + def __repr__(self): + return f"DispersionCorrection({self.name})" + + +class Functional(Keyword): + """Functional for a DFT method""" + + def __init__( + self, + name, + doi=None, + doi_list=None, + freq_scale_factor: float = 1.0, + **kwargs, + ): + super().__init__(name, doi=doi, doi_list=doi_list, **kwargs) + + self.freq_scale_factor = freq_scale_factor + + def __repr__(self): + return f"Functional({self.name})" + + def __eq__(self, other): + return isinstance(other, Functional) and self.name == other.name + + def __hash__(self): + return hash(self.name) + + +class ImplicitSolventType(Keyword): + """ + A type of implicit solvent model. Example:: + + cpcm = ImplicitSolventType(name='cpcm', doi='10.the_doi') + """ + + def __repr__(self): + return f"ImplicitSolventType({self.name})" + + +class RI(Keyword): + """Resolution of identity approximation""" + + def __repr__(self): + return f"ResolutionOfIdentity({self.name})" + + +class WFMethod(Keyword): + """Keyword for a wavefunction method e.g. HF or CCSD(T)""" + + def __repr__(self): + return f"WaveFunctionMethod({self.name})" + + +class ECP(Keyword): + """Effective core potential""" + + def __repr__(self): + return f"EffectiveCorePotential({self.name})" + + def __eq__(self, other: object): + """Equality of ECPs""" + return ( + isinstance(other, ECP) + and str(self) == str(other) + and self.min_atomic_number == other.min_atomic_number + ) + + def __hash__(self): + """Unique hash of this effective core potential""" + return hash(str(self) + str(self.min_atomic_number)) + + def __init__( + self, + name: str, + min_atomic_number: int = 37, + doi: Optional[str] = None, + doi_list: Optional[List[str]] = None, + **kwargs, + ): + """ + An effective core potential that applies to all atoms with atomic + numbers larger than min_atomic_number + + ----------------------------------------------------------------------- + Arguments: + name (str): + min_atomic_number (int): + doi (str): + doi_list (list(str)): + kwargs: + """ + super().__init__(name, doi=doi, doi_list=doi_list, **kwargs) + + self.min_atomic_number = min_atomic_number + + +class MaxOptCycles(Keyword): + """Maximum number of optimisation cycles""" + + def __repr__(self): + return f"MaxOptCycles(N = {self.name})" + + def __int__(self): + return int(self.name) + + def __init__(self, number: int): + super().__init__(name=str(int(number))) + + +_KEYWORDS_TYPE = Union[Keywords, Sequence[Union[Keyword, str]]] diff --git a/autodE/source/autode/wrappers/keywords/ri.py b/autodE/source/autode/wrappers/keywords/ri.py new file mode 100644 index 0000000000000000000000000000000000000000..63c553024a0b1180e2b70a0827df14512aa3b3bb --- /dev/null +++ b/autodE/source/autode/wrappers/keywords/ri.py @@ -0,0 +1,5 @@ +from autode.wrappers.keywords.keywords import RI + +rijcosx = RI( + name="rijcosx", doi_list=["10.1016/j.chemphys.2008.10.036"], orca="RIJCOSX" +) diff --git a/autodE/source/autode/wrappers/keywords/wf.py b/autodE/source/autode/wrappers/keywords/wf.py new file mode 100644 index 0000000000000000000000000000000000000000..2d4a848132410d79804b2a37a2433b1bd7dbba82 --- /dev/null +++ b/autodE/source/autode/wrappers/keywords/wf.py @@ -0,0 +1,3 @@ +from autode.wrappers.keywords.keywords import WFMethod + +hf = WFMethod(name="HF", orca="HF") diff --git a/autodE/source/autode/wrappers/methods.py b/autodE/source/autode/wrappers/methods.py new file mode 100644 index 0000000000000000000000000000000000000000..b0a69d130efc1af788bfedba4d8a5e128c533819 --- /dev/null +++ b/autodE/source/autode/wrappers/methods.py @@ -0,0 +1,313 @@ +from abc import ABC, abstractmethod +from copy import deepcopy +from shutil import which +from typing import Optional, List, TYPE_CHECKING +from pathlib import Path + +from autode.log import logger +from autode.values import PotentialEnergy, Gradient, Coordinates +from autode.hessians import Hessian +from autode.exceptions import NotImplementedInMethod +from autode.wrappers.keywords import ImplicitSolventType, KeywordsSet +from autode.calculations.types import CalculationType as ct + + +if TYPE_CHECKING: + from autode.calculations.executors import CalculationExecutor + from autode.calculations.types import CalculationType + from autode.opt.optimisers.base import BaseOptimiser + from autode.atoms import Atoms + + +class Method(ABC): + def __init__( + self, name: str, keywords_set: KeywordsSet, doi_list: List[str] + ): + """ + A base autodE method wrapper, capable of setting energies/gradients/ + Hessians of a molecule + + ----------------------------------------------------------------------- + Arguments: + name: Name of this method + + keywords_set: Set of keywords to use for different types of + calculations + + doi_list: List of digital object identifiers (DOIs) + """ + + self._name = name + self.keywords = keywords_set.copy() + self.implicit_solvation_type: Optional[ImplicitSolventType] = None + self.doi_list = doi_list + + @property + def name(self) -> str: + """ + Name of this method. e.g. "g09" for Gaussian 09 + """ + return self._name + + def execute(self, calc: "CalculationExecutor") -> None: + pass + + @property + @abstractmethod + def uses_external_io(self) -> bool: + """ + Does this method generate an input/output file that needs to be parsed + to find the required properties e.g. energy of the input molecule. + """ + + @abstractmethod + def __repr__(self): + """Representation of this method""" + + @abstractmethod + def implements(self, calculation_type: "CalculationType") -> bool: + """Does this method implement a particular calculation type?""" + + def terminated_normally_in(self, calc: "CalculationExecutor") -> bool: + """Did the calculation terminate normally?""" + return True + + @property + def doi_str(self): + return " ".join(self.doi_list) + + @property + def available_implicit_solvents(self) -> List[str]: + """Available implicit solvent models for this EST method""" + from autode.solvent.solvents import solvents + + return [ + solvent.name + for solvent in solvents + if solvent.is_implicit and getattr(solvent, self.name) is not None + ] + + @property + def is_available(self): + """Is this method available?""" + return True + + def version_in(self, calc: "CalculationExecutor") -> str: + """Determine the version of the method used in this calculation""" + return "???" + + def _all_equal(self, other, attrs) -> bool: + return all(getattr(other, a) == getattr(self, a) for a in attrs) + + def __eq__(self, other) -> bool: + """Equality of this method to another one""" + + if not isinstance(other, self.__class__): + return False + + return self._all_equal(other, attrs=("name", "keywords")) + + def copy(self) -> "Method": + return deepcopy(self) + + def optimiser_from(self, calc: "CalculationExecutor") -> "BaseOptimiser": + raise NotImplementedInMethod + + def energy_from(self, calc: "CalculationExecutor") -> PotentialEnergy: + raise NotImplementedInMethod + + def gradient_from(self, calc: "CalculationExecutor") -> Gradient: + raise NotImplementedInMethod + + def hessian_from(self, calc: "CalculationExecutor") -> Hessian: + raise NotImplementedInMethod + + def coordinates_from(self, calc: "CalculationExecutor") -> Coordinates: + raise NotImplementedInMethod + + def atoms_from(self, calc: "CalculationExecutor") -> "Atoms": + raise NotImplementedInMethod + + def partial_charges_from(self, calc: "CalculationExecutor") -> List[float]: + raise NotImplementedInMethod + + @staticmethod + def input_filename_for(calc: "CalculationExecutor") -> str: + raise NotImplementedInMethod + + @staticmethod + def output_filename_for(calc: "CalculationExecutor") -> str: + raise NotImplementedInMethod + + def generate_input_for(self, calc: "CalculationExecutor") -> None: + return None + + +class ExternalMethod(Method, ABC): + def __init__( + self, + executable_name: str, + keywords_set: KeywordsSet, + doi_list: List[str], + implicit_solvation_type: Optional[ImplicitSolventType], + path: Optional[str] = None, + ): + """ + An autodE wrapped method that calls an executable to generate an output + file + + ----------------------------------------------------------------------- + Arguments: + executable_name: Name of the executable to call e.g. orca + + implicit_solvation_type: Type of implicit solvent + + path: Full file path to the executable. Overrides the path found + when calling + + See Also: + + :py:meth:`Method ` + """ + super().__init__( + name=executable_name, keywords_set=keywords_set, doi_list=doi_list + ) + + self.implicit_solvation_type = implicit_solvation_type + self.path = path if path is not None else which(executable_name) + + @property + def is_available(self): + """Is this method available?""" + logger.info(f"Setting the availability of {self.name}") + + if self.path is not None: + if Path(self.path).exists(): + logger.info(f"{self.name} is available") + return True + + logger.info(f"{self.name} is not available") + return False + + @abstractmethod + def execute(self, calc: "CalculationExecutor") -> None: + """Run this calculation and generate an output file""" + + @abstractmethod + def terminated_normally_in(self, calc: "CalculationExecutor") -> bool: + """Did the calculation terminate normally?""" + + @abstractmethod + def optimiser_from(self, calc: "CalculationExecutor") -> "BaseOptimiser": + """ + Optimiser that this method used. Set from the calculation output + """ + + def energy_from(self, calc: "CalculationExecutor") -> PotentialEnergy: + """ + Get an energy with a set of associated attributes, defined by the + method which was used to execute the calculation. + """ + logger.info(f"Getting energy from {calc.output.filename}") + + energy = self._energy_from(calc) + if energy is not None: + energy.set_method_str(method=self, keywords=calc.input.keywords) + + return energy + + @abstractmethod + def _energy_from(self, calc: "CalculationExecutor") -> PotentialEnergy: + """ + Extract the energy from this calculation + """ + + @abstractmethod + def gradient_from(self, calc: "CalculationExecutor") -> Gradient: + """ + Extract the gradient from this calculation + """ + + @abstractmethod + def hessian_from(self, calc: "CalculationExecutor") -> Hessian: + """ + Extract the Hessian from this calculation + """ + + @abstractmethod + def coordinates_from(self, calc: "CalculationExecutor") -> Coordinates: + """ + Extract the final set of atomic coordinates from the output file. They + *must* be in the same order as they were specified + """ + + def atoms_from(self, calc: "CalculationExecutor") -> "Atoms": + """ + Extract the atoms from a completed calculation + """ + + atoms = calc.molecule.atoms.copy() + atoms.coordinates = self.coordinates_from(calc) + return atoms + + @abstractmethod + def partial_charges_from(self, calc: "CalculationExecutor") -> List[float]: + """ + Extract the partial atomic charges corresponding to the final geometry + present in the output file + """ + + @abstractmethod + def version_in(self, calc: "CalculationExecutor") -> str: + """Determine the version of the method used in this calculation""" + + @property + def uses_external_io(self) -> bool: + return True + + @staticmethod + @abstractmethod + def input_filename_for(calc: "CalculationExecutor") -> str: + """Determine the input filename for a calculation""" + + @staticmethod + @abstractmethod + def output_filename_for(calc: "CalculationExecutor") -> str: + """Determine the output filename for a calculation""" + + @abstractmethod + def generate_input_for(self, calc: "CalculationExecutor") -> None: + """Generate the input required for a calculation""" + + def __eq__(self, other) -> bool: + attrs = ("name", "keywords", "path", "implicit_solvation_type") + return isinstance(other, self.__class__) and self._all_equal( + other, attrs + ) + + +class ExternalMethodOEG(ExternalMethod, ABC): + """External method that implements optimisation, energy and gradient""" + + def implements(self, calculation_type: "CalculationType") -> bool: + return calculation_type in (ct.opt, ct.energy, ct.gradient) + + def hessian_from(self, calc: "CalculationExecutor") -> Hessian: + raise NotImplementedInMethod + + +class ExternalMethodOEGH(ExternalMethod, ABC): + """External method that implements opt, energy, gradient and Hessians""" + + def implements(self, calculation_type: "CalculationType") -> bool: + return calculation_type in (ct.opt, ct.energy, ct.gradient, ct.hessian) + + +class ExternalMethodEGH(ExternalMethod, ABC): + """External method that implements energy, gradient and Hessians""" + + def implements(self, calculation_type: "CalculationType") -> bool: + return calculation_type in (ct.energy, ct.gradient, ct.hessian) + + def optimiser_from(self, calc: "CalculationExecutor") -> "BaseOptimiser": + raise NotImplementedInMethod diff --git a/autodE/source/doc/Makefile b/autodE/source/doc/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..08835bdab821950bf93f632a6f2e29980d6949d6 --- /dev/null +++ b/autodE/source/doc/Makefile @@ -0,0 +1,19 @@ +# Minimal makefile for Sphinx documentation + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/autodE/source/doc/README.md b/autodE/source/doc/README.md new file mode 100644 index 0000000000000000000000000000000000000000..b2cdb545d9d44368f2619a014d181c3bc1cf690d --- /dev/null +++ b/autodE/source/doc/README.md @@ -0,0 +1,21 @@ +# Documentation + +Documentation can be found at +[duartegroup.github.io/autodE](https://duartegroup.github.io/autodE/) + +To build the html docs manually, in this directory first install the dependencies + +``` +conda install --file requirements.txt +``` + +and make with + +``` +make html +``` + +or to build the pdf +``` +make pdf +``` \ No newline at end of file diff --git a/autodE/source/doc/changelog.rst b/autodE/source/doc/changelog.rst new file mode 100644 index 0000000000000000000000000000000000000000..238a6d6c1cc69ad8bac45f97716da37ab107cfd0 --- /dev/null +++ b/autodE/source/doc/changelog.rst @@ -0,0 +1,792 @@ +Changelog +========= + +1.4.5 +------ +------- + +Functionality improvements +************************** +- Adds a boolean option for calculating single points refinements + + +Bug Fixes +********* +- Fixes coordinate extraction in some G16 output files +- Fixes loading old mlptrain .npz files +- Fixes compatability with RDKit 2025.03.2 + +Usability improvements/Changes +****************************** +- Drops Python 3.8 support +- Catches conformer calculation exceptions + +1.4.4 +------ +------- + +Functionality improvements +************************** +- Improved constrained optimisation (:code:`CRFOptimiser`) and handling of multiple constraints +- Adds compatability with numpy v2.0 +- Improved implementation of the RFO-TRM (:code:`QAOptimiser`) optimiser that can handle constraints +- Added static internal back-transform and damping for faster and easier DIC to Cartesian coordinate transformation + +Bug Fixes +********* +- DIC to Cartesian transform will now always use :code:`PIC.close_to()` to ensure steps along dihedral have the smallest change, even after back-transform is complete + +Usability improvements/Changes +****************************** +- Optimiser convergence criteria have been improved to consider energy change, RMS and max. gradient and step sizes. + +1.4.3 +------ +------- + +Functionality improvements +************************** +- DHS and DHS-GS can now switch between two step sizes +- Peak detection in bracket methods now uses cubic polynomial fit with energies and gradients instead of only projecting gradients + +Bug Fixes +********* +- Fixes no solvent being added in QRC calculations +- Fixes cases where .xyz files are printed with no space between coordinates when the coordinate value is large. +- Fixes DHS and DHS-GS methods ignoring number of cores + +Usability improvements/Changes +****************************** +- Added consistent aliases for double dagger across all energies in :code:`autode.reaction.delta` +- Optimiser trajectories are saved on disk instead of keeping completely in memory +- Hessian updates the refactored into :code:`OptCoordinates` class + +1.4.2 +------ +------- + +Functionality improvements +************************** +- Replaces hard-coded derivatives for primitive internal coordinates with automatic differentiation +- More comprehensive primitive internal coordinate generation +- Adds the capacity to handle linear molecules in internal coordinate system + +Bug Fixes +********* +- Fixes triangular rings being incorrectly treated as dihedral angles + +Usability improvements/Changes +****************************** +- Faster import of autode package by lazily loading matplotlib +- ORCA output files copied after a calculation are configurable + +1.4.1 +------ +------- + +Functionality improvements +************************** +- Adds the thermochemistry method from A. Otlyotov, Y. Minenkov in https://doi.org/10.1002/jcc.27129 +- Adds the improved Elastic Image Pair (i-EIP) method for double-ended transition state search +- Adds a :code:`autode.Species.solvent_name` property and setter for setting solvents from a string +- Enables reloading molecules from xyz files with their previously defined charge/multiplicity/solvent + +Bug Fixes +********* +- Fixes Hessian extraction in some G16 output files +- Fixes large step sizes in DHSGS +- Fixes the ability to define both a SMILES string and a xyz file without raising an exception + + +1.4.0 +-------- +---------- + +Functionality improvements +************************** +- Adds :code:`temporary_config()` context manager for temporary configuration changes +- Adds a :code:`ForceConstant` value +- Adds a more robust hybrid RFO-TRM geometry optimiser with trust radius update and a feature to detect oscillation and damp it +- Adds Dewar-Healy-Stewart (DHS) method and its variant DHS-GS to find transition states from initial and final geometries + + +Usability improvements/Changes +****************************** +- Adds full usability of autodE on Windows, including parallelisation with :code:`loky` +- Optional timeout for graph isomorphism test in Windows, turned on by :code:`Config.use_experimental_timeout=True` (default behaviour kept for Linux/macOS) +- The electronic temperature and the version of parameterisation for xTB calculations are made configurable +- A NEB :code:`Image` now derives from a :code:`Species` superclass +- Modifies NEB :code:`Image` constructor to be formed from an image +- Defines named constructors (:code:`from_end_points(...)`, :code:`from_list(...)`) for :code:`NEB` +- Removes :code:`NEB().contains_peak()` in favour of :code:`NEB().images.contains_peak` +- Modifies the :code:`CImages` constructor to ensure it's constructed from an :code:`Images` instance +- Removes :code:`NEB.get_species_saddle_point()` in favour of :code:`NEB.peak_species` +- Defines named constructors (:code:`from_end_points(...)`, :code:`from_list(...)`) for :code:`NEB` +- Removes :code:`mol_graphs.get_atom_ids_sorted_type()` + +Bug Fixes +********* +- Fixes pickling issue with :code:`autode.config.Config` on Windows and in multiprocessing spawn for Linux/macOS +- Fixes RFO Hessian update bug +- Fixes QChem not using :code:`Config.max_core` + + +1.3.5 +-------- +---------- + + +Usability improvements/Changes +****************************** +- :code:`autode.value.ValueArray.to()` now defaults to copying the object rather than inplace modification + + +Functionality improvements +************************** +- Adds a :code:`to_` method to :code:`autode.value.ValueArray` for explicit inplace modification of the array + + +Bug Fixes +********* +- Fixes :code:`ERROR` logging level being ignored from environment variable :code:`AUTODE_LOG_LEVEL` +- Fixes :code:`autode.values.Value` instances generating items with units on division, and throw a warning if multiplying +- Fixes the printing of cartesian constraints in XTB input files, meaning they are no longer ignored +- Fixes :code:`Hessian` instances changing units when normal modes are calculated +- Fixes an incorrect alias for :code:`ev_per_ang` + + +1.3.4 +-------- +---------- + +Feature additions. + +Usability improvements/Changes +****************************** +* Throw useful exception for invalid :code:`ade.Config.ts_template_folder_path` + + +Functionality improvements +************************** +- Adds :code:`ade.transition_states.TransitionState.from_species` method to construct transition states from a species or molecule +- Adds :code:`autode.Reaction.save()` and :code:`autode.Reaction.load()` to save and reload a reaction state +- Adds saving checkpoints of a reaction during :code:`autode.Reaction.calculate_reaction_profile` + + +Bug Fixes +********* +- Fixes calculation :code:`clean_up()` failing with a null filename + + +1.3.3 +-------- +---------- + +Bugfix release. + + +Functionality improvements +************************** +- Adds skipping Hessian re-evaluation when using autodE optimisers if a molecules has a Hessian calculated at the same level of theory +- Adds a Hessian recalculation frequency to :code:`autode.optimisers.PRFOptimiser` +- Improves the default step size for TS optimising to be consistent with the ORCA default + +Bug Fixes +********* +- Adds checking of SMILES-defined charge against the user-specified value +- Fixes :code:`autode.optimisers.CRFOptimiser` building incomplete internal coordinates for partially or completely fragmented molecules + + +1.3.2 +-------- +---------- + +Bugfix release. + + +Usability improvements/Changes +****************************** +* Removes :code:`autode.geom.get_distance_constraints` in favour of a better named method :code:`TSBase().active_bond_constraints` + + +Bug Fixes +********* +- :code:`autode.transition_states.ts_guess.TSguess.from_species` now inherits solvent from the species +- Fixes a possible race condition in I/O for XTB conformer optimisations + + +1.3.1 +-------- +---------- + +Bugfix release. + + +Bug Fixes +********* +- Fixes behaviour of :code:`autode.utils.work_in_tmp_dir` and :code:`autode.utils.work_in` decorators +- Fixes an exception being raised when :code:`autode.Calculation.clean_up` is called with a method that doesn't implement external I/O +- Fixes autodE driven optimisations skipping execution when the input but not name changes + + +1.3.0 +-------- +---------- + +Optimisation features, graph assignment improvements and bugfixes. + + +Usability improvements/Changes +****************************** +* Defines dummy atoms to have zero covalent and vdW radii +* Renames :code:`Method().available` to :code:`Method().is_available` +* Removes :code:`autode.bonds.get_ideal_bond_length_matrix` and :code:`autode.bonds.get_avg_bond_length` +* Removes :code:`autode.geom.rotate_columns` +* Modifies the names of most optimiser classes e.g. :code:`autode.opt.optimisers.PRFOOptimiser` -> :code:`PRFOptimiser` +* Simplifies initialising a :code:`autode.calculations.Calculation` by: + + * Requiring constraints to be attributed to a molecule + * Removing the :code:`bond_ids_to_add` argument and using the labeled graph instead (active edges) + * Removing the :code:`other_input_block` argument and appending to the keywords instead + +* Removes :code:`autode.calculations.Calculation.print_final_output_lines` in favour of a method on :code:`calculation.output` +* Makes many methods in :code:`autode.calculations.Calculation` private +* Deprecates all :code:`autode.calculations.Calculation.get_` methods in favour of setting properties of the input molecule +* Returns :code:`None` rather than rasies exceptions when calling the (deprecated) calculation get methods, to be consistent with ...get_energy +* Adds an :code:`autode.wrappers.keywords` package to improve file structure +* Removes any exceptions on calling :code:`.run()` on an optimiser instance where the system has no degrees of freedom +* Removes support for Python < v3.8 +* Tweaks the default ORCA TS optimisation keywords to be more conservative, i.e. slower and more accurate + + +Functionality improvements +************************** +- Adds a :code:`autode.atoms.Atom.covalent_radius` property +- Adds a :code:`autode.atoms.Atoms.eqm_bond_distance` method for the equilibrium bonded distance between two atoms +- Adds vibrational frequency scaling through both :code:`autode.Config.freq_scale_factor` and a default value in wrapped functional keywords +- Adds a *much* more robust constrained rational function constrained optimiser in delocalised internal coordinates (DIC) +- Adds bond angle and dihedral primitive coordinates which can form part of the DIC set +- Improves the back transformation +- Adds an optional callback argument to :code:`autode.opt.optimisers.base.Optimiser` for running custom functions after every optimisation step +- Adds the ability to save/reload an :code:`autode.opt.optimisers.NDOptimiser` instance to/from a file +- Adds a solvent attribute to a :code:`autode.transition_states.transition_state.TransitionState` constructor +- Adds functionality to partition a nudged elastic band into images where the maximum atom-atom distance between images is below a threshold +- Adds a sequential adapt+NEB TS finding method where a pure adapt. path fails to generate a geometry close enough to the TS for a successful TS optimisation + + +Bug Fixes +********* +- Fixes variable harmonic frequencies (<2 cm-1 differences) due to projection vectors becoming close to rotational axes +- Fixes the extraction of atomic partial charges from ORCA output files +- Fixes gradients and Hessians not being reset on a molecule where the coordinates change +- Fixes unhelpful exception when calculating thermochemistry with EST methods without implemented "get_hessian" methods + + +See the table below for a quick benchmark of constrained optimisations in autodE +compared to ORCA. In all cases the structures were generated from SMILES strings (RDKit) +and optimised with a single constraint on the (0,1) distance of +0.1 Å from its current +value. + +.. list-table:: + :header-rows: 1 + + * - Molecule + - autodE + - ORCA + * - C7H12 + - 6 + - 7 + * - C3H7 + - 8 + - 17 + * - C4H6 + - 3 + - 5 + * - CClH3 + - 3 + - 4 + * - C2H3O2 + - 3 + - 7 + * - C2FH5 + - 3 + - 5 + * - C4H6O2S + - 6 + - 11 + +1.2.3 +-------- +---------- + +Minor functionality improvements and bugfixes. + + +Usability improvements/Changes +****************************** +- All exceptions now inherit from a base :code:`autode.exceptions.AutodeException` +- Fixes a typo in :code:`autode.exceptions.UnsupoportedCalculationInput` +- Adds documentation explaining the intention of each exception in :code:`autode.exceptions` +- Molecular graphs are now 'laziliy-loaded' i.e. generated once when the property is accessed + + +Functionality improvements +************************** +- Adds the ability to define atom classes for molecules in turn allowing for identity reactions to be calculated + + +Bug Fixes +********* +- Fixes clashing names for a reaction initialised explicitly from molecules without defined names + + +1.2.2 +-------- +---------- + +Bugfix release. + + +Bug Fixes +********* +- Fixes output redirection from XTB calculations resulting in missed lines on Mac + + +1.2.1 +-------- +---------- + +Bugfix release. + + +Functionality improvements +****************************** +- Adds :code:`autode.mol_graphs.MolecularGraph` (subclass of :code:`networkx.Graph`) with a :code:`expected_planar_geometry` method +- Adds an :code:`are_planar` method to :code:`autode.atoms.Atoms` + + +Bug Fixes +********* + +- Fixes a bug in assigning reasonable geometries which, in turn, could lead to skipped single point energy evaluations + + +1.2.0 +-------- +---------- + +Adds optimisation algorithms experimental explicit solvation, improves potential energy surface +module as well as an array of usability improvements. + + +Usability improvements/Changes +****************************** +- Adds more type hints and documentation +- Updates the TS template saved in the default template library +- Adds a setter for :code:`autode.species.Species.solvent` so :code:`mol.solvent = 'water'` will assign a :code:`autode.solvent.Solvent` +- Removes :code:`autode.calculation.CalculationInput.solvent` as an attribute in favour of using the molecule's solvent +- Removes :code:`autode.calculation.get_solvent_name` in favour of a molecule check +- Removes :code:`autode.species.molecule.reactant_to_product` in favour of a :code:`to_product()` method for :code:`autode.species.molecule.Reactant` (and likewise with a Reactant) +- Removes partially implemented :code:`autode.species.molecule.SolvatedMolecule` and :code:`autode.species.complex.SolvatedReactantComplex` as the type of solvation (implicit/explicit) should be a property of the solvent and not the molecule +- Removes :code:`autode.reactions.Reaction.calc_deltaXXX` in favour of :code:`autode.reactions.Reaction.delta()` +- Refactors classes to place constructors at the top +- Removes :code:`autode.values.PlottedEnergy` as an estimated attribute is useful for all energies, not just those that are plotted +- Removes :code:`autode.reactions.Reaction.find_lowest_energy_ts` as the function is not well named and can be replaced by a :code:`autode.reactions.Reaction.ts` property +- Adds :code:`autode.transition_states.TransitionStates` as a wrapper for TSs, much like :code:`autode.conformers.Conformers` +- Updates :code:`autode.solvent.solvents.get_solvent` to require specifying either an implicit or explicit solvent +- Improves validation of distance constraints and adds invariance to the key order i.e. :code:`autode.constraints.distance[(0, 1)] == autode.constraints.distance[(1, 0)]` +- Removes :code:`autode.KcalMol` and :code:`KjMol` and enables a reaction to be plotted using a string representation of the units. +- Allows for keywords to be set using just a list or a string, rather than requiring a specific type +- Changes :code:`autode.wrappers.keywords.Keyword.has_only_name` to a property +- Modifies the constructor of :code:`autode.species.molecule.Molecule` to allow for a name to be specified when initialising from a .xyz file +- Modifies :code:`autode.calculation.Calculation.get_energy` to raise an exception if the energy cannot be extracted +- Adds a runtime error if e.g. :code:`autode.calculation.Calculation.get_energy` is called on a calculation that has not been run +- Skips low-level adaptive path searching if the high and low-level methods are identical (when XTB or MOPAC are not installed) +- Adds a default set of low-level single point keywords +- Adds a flag to override exiting a reaction profile calculation when association complexes are generated +- Adds a check that a calculation isn't going to exceed the maximum amount of physical memory on the computer + + +Functionality improvements +************************** + +- Adds a selection of molecule optimisers to locate minima and transition states +- Refactors :code:`autode.smiles.angles` to use unique class names (preventing overlap with e.g. :code:`autode.values.Angle`) +- Adds a :code:`autode.solvent.Solvent.dielectric` property for a solvent's dielectric constant +- Adds a :code:`autode.solvent.Solvent.is_implicit` property +- Adds methods (e.g. translate and rotate) to :code:`autode.point_charges.PointCharge` +- Adds checking that both high and low-level electronic structure methods are available before running :code:`autode.reaction.Reaction.calculate_reaction_profile` or :code:`calculate_reaction_profile` +- Adds a more robust explicit solvation generation (:code:`autode.species.molecule.Molecule.explicitly_solvate()`) +- Removes criteria on using a TS template with large distance differences between the structure and the template in favour of running sequential constrained optimisations to the required point +- Rewrites :code:`autode.pes` into a consistent module while maintaining much of the functionality. Simplifies the interface +- Adds a QChem electronic structure method wrapper +- Adds :code:`autode.species.Species.calc_hessian` to calculate either an analytic or numerical Hessian (in parallel) +- Adds image dependent pair potential (IDPP) relaxation improved interpolated geometries +- Adds :code:`autode.hessians.HybridHessianCalculator` to calculate numerical Hessians at two levels of theory + + +Bug Fixes +********* + +- Updates the TS template saved in the default template library +- Reloads output file lines from a failed then re-run calculation +- Fixes Hessian extractions from some Gaussian output files + + +1.1.3 +-------- +---------- + +Usability improvements + +Usability improvements/Changes +****************************** +- Improves consistency and behaviour of :code:`calc_thermo` method of a species, allowing for keywords and non-run calculations +- Allows for a non-fork multiprocessing 'start_method' + + +1.1.2 +-------- +---------- + +Bugfixes + +Usability improvements/Changes +****************************** +- Fixes typo in :code:`autode.exceptions.ReactionFormationFalied` + +Bug Fixes +********* + +- Fixes a bug where rings containing mostly double bonds failed to build with :code:`autode.smiles.builder.Builder` +- Fixes using XTB as a high-level method with the xtb-gaussian wrapper (thanks @kjelljorner) + + +1.1.1 +-------- +---------- + +Documentation and typing hints + +Usability improvements/Changes +****************************** +- Adds `typing `_ to user-facing functions +- Adds :code:`autode.config.location` to easily locate the core configuration file for permanent editing +- Updates documentation for readability +- Ensures units are kept if constructing a :code:`Value` from a :code:`Value` (i.e. :code:`Value(x)`, when :code:`x` is a :code:`Value`) + + +Functionality improvements +************************** + +- Changes :code:`Keyword` to an abstract base class +- Improves speed of :code:`Species` rotation (numpy rather than a Python for loop) + + +Bug Fixes +********* + +- Fixes bug where NCI conformers were generated with the same name thus did not optimise uniquely (introduced in v.1.1.0) + + +1.1.0 +-------- +---------- + +API improvements that broadly maintain backwards compatibility. + + +Usability improvements/Changes +****************************** +- Adds more argument and return types +- Changes :code:`AtomCollection.atoms` to a property for more flexible sub-classing +- Changes :code:`ElectronicStructureMethod.doi_str` and :code:`Keyword.doi_str` to properties +- Adds interpretable :code:`repr(Species)` +- :code:`Species.energies` is zeroed when the :code:`Species.atoms` are reset or change +- :code:`Species.energy` is a property of the last computed energy on that species +- :code:`Species.is_linear` now uses an angle tolerance to determine linearity, which is slightly tighter than the previous float-based tolerance +- Removes :code:`CalculationOutput.set_lines` in favour of a cached file_lines property to avoid :code:`set_file_lines()` +- Removes :code:`CalculationOutput.get_free_energy()` in favour of :code:`Species.free_energy` once a Hessian is set for a molecule and similarly with :code:`CalculationOutput.get_enthalpy()` +- Removes :code:`CalculationOutput.get_imaginary_freqs()` (now :code:`Species.imaginary_frequencies`) and :code:`CalculationOutput.get_normal_mode_displacements()` (now :code:`Species.normal_mode()`) +- :code:`Species.imaginary_frequencies` now returns :code:`None` rather than an empty list for a species without any imaginary frequencies, to be consistent with other properties +- Changes :code:`CalculationOutput.terminated_normally()` to a property (:code:`CalculationOutput.terminated_normally`) +- Removes :code:`Reaction.find_complexes` in favour of setting the reactant and product complexes dynamically, unless :code:`Reaction.calculate_complexes` is called to find association complexes +- Tweaks the default relative tolerance on bonds to account for M-X agostic interactions lengthening bonds +- Enables :code:`Species.atoms` to be added, even if they are `None` +- Improved atom setting of :code:`Complex.atoms` +- Changes :code:`Complex.get_atom_indexes()` to :code:`Complex.atom_indexes()` +- Changes :code:`Complex.molecules` to a private attribute as the atoms/energy/gradient is not propagated +- Allows for :code:`Species.translate()` and :code:`Species.rotate()` to be called using vectors as lists or tuples rather than just numpy arrays +- Modifies :code:`get_truncated_complex()` to :code:`get_truncated_species()` and changes the return type to a species to reflect a possibly different molecular composition of the complex +- Improves peak checking in adaptive path TS guess generation +- Removes :code:`autode.atoms.get_thing()` functions, in favour of :code:`Atom.thing` +- Raises an exception if a single point energy evaluation fails to execute successfully +- Removes :code:`autode.conformers.conformer.get_conformer()` in favour of a more flexible :code:`autode.conformer.Conformer` constructor +- Adds :code:`Species.constraints` that are used in optimisations (still available in :code:`Calculation` initialisation) +- Adds :code:`Conformers` to enable parallel electronic structure calculations across a set of conformers +- Improves readability of pruning of conformers based on RMSD and energy thresholds + + +Functionality improvements +************************** + +- Adds angle and dihedral angle properties to an :code:`AtomCollection` +- Improves and adds more :code:`Unit` definitions +- Adds :code:`Value` and :code:`ValueArray` base classes for energies, gradients etc. These allow for implicit (1 Hartree == 617.509 kcal mol-1) comparisons and explicit conversion (1 Hartree).to('kcal') +- Adds further conversion factors to :code:`Constants` +- Adds :code:`Species.energies` as a container of all energies that have been calculated at a geometry +- Adds :code:`Keywords.bstring` as a 'brief' summary of the keywords e.g. PBE0/def2-SVP and are associated with an :code:`Energy` (a type of :code:`Value`) +- Improves quick reaction coordinate characterisation of TSs by providing a maximum atomic displacement for improved initial structures +- Adds Hessian diagonalisation to obtain normal modes with and without translation and rotation projections for linear and non-linear molecules +- Adds :code:`Species.weight` and :code:`Species.mass` as equivalent properties for the molecular weight +- Improves dihedral sampling in molecule generation +- Adds :code:`atoms.remove_dummy()` to remove all dummy atoms from a set +- Enables different force constants to be used in XTB constrained optimisations (:code:`Config.XTB.force_constant`, which sets :code:`wrappers.XTB.XTB.force_constant`) +- Adds :code:`Solvent.copy()` +- Adds :code:`Species.reorder_atoms()` to reorder the atoms in a species using a mapping +- Adds :code:`Config.ORCA.other_input_block` to allow for a block of input to be printed in all ORCA input files +- Changes the loose optimisations to only use a maximum of 10 iterations. This is based on an analysis of 3500 ORCA +optimisations, which plateaus quickly: + +.. image:: common/opt_convergence_3500_ORCA.png + :width: 500 + +suggesting a value of 10 is a appropriate. This will be system dependent and need increasing for +large/flexible systems. For path optimisations loose optimisations use a maximum of 50 cycles. + + +Bug Fixes +********* + +- Skips conformers with no atoms in finding unique conformers +- Corrects benchmark TS location for the Grubbs metathesis example, where the reactant complex is bound +- Fixes possible zero distance constraint for a single atom +- Fixes spin state definition for XTB calculations +- Fixes possible override of a constructor-defined spin state by the SMILES parser + + +1.0.5 +-------- +---------- + +Bugfix release + +Bug Fixes +********* +- Saves transition state templates with correct atom labels + + +1.0.4 +-------- +---------- + +Bug fixes in SMILES parser and 3D geometry builder from 1.0.3. + + +Usability improvements +********************** + +- Improves doc strings +- Throws interpretable error when calling :code:`find_tss` without :code:`reaction.reactant` set + +Functionality improvements +************************** + +- SMILES strings with >9 ring closures are parsed correctly +- cis-double bonds in rings no longer minimise with constraints, which is a little faster + +Bug Fixes +********* +- Tweaks repulsion parameters in minimisation to build fused rings +- Enables SMILES parsing with "X(...)1" branching +- Fixes spin multiplicity for odd numbers of hydrogens +- Improves ring closure 3D build +- Fixes incorrect implicit valency for aromatic heteroatoms +- Improves metal finding in SMILES strings with regex +- Corrects atom type for sp2 group 16 elements +- Fixes dihedral rotation with atoms not close to any other + + +1.0.3 +-------- +---------- + +A minor API revision from 1.0.2 but adds C++ extension which should be extensible to +further developments of fast C-based code. + +Usability improvements +********************** + +- :code:`autode.Species()` inherit from a :code:`AtomCollection()` base class for more flexibility + +- :code:`autode.Constants` attributes have more readable names (while retaining backwards compatability) + +- :code:`autode.geom.length()` as an explicit alias of :code:`np.linalg.norm` has been removed + +- :code:`autode.input_output.xyz_file_to_atoms()` throws more informative errors + +- :code:`autode.mol_graphs.make_graph()` throws NoAtomsInMolecule for a species with no atoms + +- :code:`species.formula` and :code:`species.is_explicitly_solvated` are now a properties + +- :code:`autode.smiles.parser` has been rewritten & is (hopefully) a more robust SMILES parser + + +Functionality improvements +************************** + +- Metal complex initial geometries can now be generated with the correct stereochemistry + +- Macrocycles default to an **autodE** builder that conserves SMILES stereochemistry (`RDKit#1852 `_) + +- :code:`species.coordinates` can be set from either 3xN matrices or 3N length vectors + +- :code:`autode.Atom()`s have :code:`.group` :code:`.period` and :code:`.tm_row` properties referring to their location in the periodic table + +- :code:`autode.atoms.PeriodicTable` added + +- :code:`species.bond_matrix` added as a property and returns a boolean array for interactions between all atom pairs + + +Bug Fixes +********* + +- :code:`reaction.calculate_complexes()` calls :code:`reaction.find_complexes()` if needed thus can be called in isolation + + + +1.0.2 +-------- +---------- + +Usability improvements +********************** + +- Effective core potentials can now be specified in :code:`Keywords()` + +- ORCA fitting basis sets now default to def2/J, which should be smaller but as accurate as AutoAux + +- Molecule initialisation from a .xyz file now checks for an odd number of electrons. For example, :code:`Molecule('H_atom.xyz')` will raise a :code:`ValueError` but :code:`Molecule('H_atom.xyz', charge=1)` or :code:`Molecule('H_atom.xyz', mult=2)` are acceptable + + +Functionality improvements +************************** + +- :code:`atom.atomic_number` has been added as an atom attribute + +- :code:`atom.atomic_symbol` is a more intuitive alias for :code:`atom.label` + + + +1.0.1 +-------- +------------ + + +Usability improvements +********************** + +- Molecular complexes can now be initialised with a reasonable geometry :code:`Complex(..., do_init_translation=True)` + + +Functionality improvements +************************** + +- :code:`species.radius` has been added as an approximate molecular radius (in Angstroms, excluding VdW radii) + + +Bug Fixes +********* + +- Final breaking bond distances are now the minimum of the product X-Y distance if present in the product, or 2x the distance. This is required for breaking bonds that cross a ring. + +- Neighbour lists for comparing possibly equivalent bond rearrangements are now compared using a sorted list + + +1.0.0 +-------- +------------ + +The first stable release! Mostly documentation updates from v.1.0.0b3 with the +package now being conda-install-able. + + +Usability improvements +********************** + +- More documentation + + +Functionality improvements +************************** + +- XTB wrapper now supports v. 6.4 (and hopefully higher) + + +Thanks to Joe, Alistair, Matina, Kjell, Gabe, Cher-Tian amongst others for their invaluable contributions. + + +1.0.0b3 +-------- +------------ + +This version brings several major changes and in some instances breaks +backwards compatibility, but does feature significant improvements in speed +and accuracy for finding transition states. + +Usability improvements +********************** + +- :code:`species.get_distance(i, j)` is now :code:`species.distance(i, j)` + +- :code:`species.set_atoms(new_atoms)` is now properly handled with a setter so :code:`species.atoms = new_atoms` will set the new atoms + +- :code:`species.n_atoms` is more robust + +- :code:`species.get_coordinates()` is now :code:`species.coordinates`, returning a numpy array copy of the species coordinates (Nx3 in Å) + +- :code:`species.centre()` will translate a species so it's coordinate centroid lies at the origin + +- PBE0/def2-SVP is now the default 'low opt' method (`keywords.low_opt`) with loose optimisation. Path exploration uses this method, thus it needs to be very close to the 'opt' level + + +Functionality improvements +************************** + +- 1D, 2D potential energy surface scans and nudged elastic band (NEB) methods to generate TS guesses from reactants have been replaced by an adaptive path search which seems to be very efficient for generating initial paths +For the prototypical SN2 between fluoride and methyl chloride the relaxed PES (PBE0-D3BJ/ma-def2-SVP/CPCM(water)) is + + +.. image:: common/adapt_surface_sn2.png + :width: 500 + +where the previously employed linear path (red) is compared to the adaptive scheme (blue, purple) and the 'true' intrinsic reaction coordinate. +With a small minimum step size a path very close to the MEP is traversed with a very small number of required constrained optimisations. This +enables NEB relaxations to be skipped and the associated limitations (corner cutting, oscillating path, optimisation in Cartesian coordinates) +avoided. This exploration is essential when a linear path over multiple bonds leads to rearrangements, e.g. an (E2) elimination reaction the +comparison for the linear, adaptive and IRC paths are shown below + + +- (CI)-NEB with adaptive force constant has been added + +- Initial path exploration from reactants is performed at the 'low_opt' level with a final breaking bond distance below. + +Previous implementations made use of a 1.5 Å additional shift for uncharged reactions +and 2.5 Å for charged, this however lead to possible final C-H distances of ~3.6 Å and steps +into unphysical regions. 1.0.0b3 uses an estimate based on the distance where the bond +is mostly broken, as below + + +.. image:: common/XY_bde_XTB.png + +where X-Y corresponds to a molecule e.g. C-C with the appropriate hydrogens added +then the BDE curve calculated at the GFN2-XTB level of theory. A multiplier of ~2 affords a +'mostly broken bond' (i.e. the distance at 3/4 of energy of the broken bond). + +- There is now a heuristic used to skip TSs that go via small rings (3, 4-membered) if there is a >4-membered equivalent (:code:`ade.Config.skip_small_ring_tss`) + + +Bug Fixes +********* + +- Calculations are now unique based on constraints, so NEB calculations executed in the same directory are not skipped with different bond rearrangements diff --git a/autodE/source/doc/citation.rst b/autodE/source/doc/citation.rst new file mode 100644 index 0000000000000000000000000000000000000000..a6b1479ac1f922a51cac6d34130f8346156086b3 --- /dev/null +++ b/autodE/source/doc/citation.rst @@ -0,0 +1,12 @@ +Citation +======== + +If **autodE** is used in a published work please consider citing the `paper `_ alongside all the methods used. + + +.. note:: + + T. A. Young, J. J. Silcock, A. J. Sterling, F. Duarte, *autodE: + Automated Calculation of Reaction Energy Profiles— Application to + Organic and Organometallic Reactions* Angew. Chem. Int. Ed. 2021, **60**, 4266. + diff --git a/autodE/source/doc/common/DA_2d.py b/autodE/source/doc/common/DA_2d.py new file mode 100644 index 0000000000000000000000000000000000000000..f9682261c3a799b4bc304f4243a906cebf650ef2 --- /dev/null +++ b/autodE/source/doc/common/DA_2d.py @@ -0,0 +1,13 @@ +import autode as ade + +ade.Config.n_cores = 10 # Distribute over 10 cores + +# PES from the current C-C distances (~1.5 Å) to broken (3.0 Å) in 10 steps +pes = ade.pes.RelaxedPESnD( + species=ade.Molecule("cyclohexene.xyz"), + rs={(0, 5): (3.0, 10), (3, 4): (3.0, 10)}, +) + +pes.calculate(method=ade.methods.XTB()) +pes.plot("DA_surface.png") +pes.save("DA_surface.npz") diff --git a/autodE/source/doc/common/DA_2d_interp.py b/autodE/source/doc/common/DA_2d_interp.py new file mode 100644 index 0000000000000000000000000000000000000000..64909393bd166165cdd97dd8921b354e09a98dce --- /dev/null +++ b/autodE/source/doc/common/DA_2d_interp.py @@ -0,0 +1,4 @@ +import autode as ade + +pes = ade.pes.RelaxedPESnD.from_file("DA_surface.npz") +pes.plot("DA_surface_interpolated.png", interp_factor=4) diff --git a/autodE/source/doc/common/DA_surface.png b/autodE/source/doc/common/DA_surface.png new file mode 100644 index 0000000000000000000000000000000000000000..c340d61f810cd8c1f9d2dd691cee98b385ae97e7 --- /dev/null +++ b/autodE/source/doc/common/DA_surface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7119e5f4e4ec49ec41b94d62f1a27eff177940c12c2ca0252431186137a7fb99 +size 1020895 diff --git a/autodE/source/doc/common/DA_surface_interpolated.png b/autodE/source/doc/common/DA_surface_interpolated.png new file mode 100644 index 0000000000000000000000000000000000000000..61e6ec1e65e060e3773d23e06f02cb4cd0978e06 --- /dev/null +++ b/autodE/source/doc/common/DA_surface_interpolated.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:150b2cc53fd1a772a15bc079dc7cdc4c69699e67239670a78955bab9a57c44fd +size 1372766 diff --git a/autodE/source/doc/common/OH_PES_comparison.png b/autodE/source/doc/common/OH_PES_comparison.png new file mode 100644 index 0000000000000000000000000000000000000000..d4ee6b4958f1667058228c3a5e3db99cbee16063 Binary files /dev/null and b/autodE/source/doc/common/OH_PES_comparison.png differ diff --git a/autodE/source/doc/common/OH_PES_relaxed.png b/autodE/source/doc/common/OH_PES_relaxed.png new file mode 100644 index 0000000000000000000000000000000000000000..730786763a38eed7124d8908cea3efa896ea2ca4 --- /dev/null +++ b/autodE/source/doc/common/OH_PES_relaxed.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b9fd3c5bf482a4693703153f64bc9b43eebadd9e507892f5c89c56b9c25dded1 +size 195100 diff --git a/autodE/source/doc/common/OH_PES_relaxed.py b/autodE/source/doc/common/OH_PES_relaxed.py new file mode 100644 index 0000000000000000000000000000000000000000..bfc3e33928a7f8988a5b8b1ddfebdf29041b5fd8 --- /dev/null +++ b/autodE/source/doc/common/OH_PES_relaxed.py @@ -0,0 +1,20 @@ +import autode as ade + +water = ade.Molecule(name="H2O", smiles="O") + +# Initialise a relaxed potential energy surface for the water O-H stretch +# from 0.65 -> 2.0 Å in 15 steps +pes = ade.pes.RelaxedPESnD(species=water, rs={(0, 1): (0.65, 2.0, 15)}) + +pes.calculate(method=ade.methods.XTB()) +pes.plot("OH_PES_relaxed.png") + +# PESs can also be saved as compressed numpy objects and reloaded +pes.save("pes.npz") + +# For example, reload the PES and print the distances and energies +pes = ade.pes.RelaxedPESnD.from_file("pes.npz") + +print("r (Å) E (Ha)") +for i in range(15): + print(f"{pes.r1[i]:.4f}", f"{pes[i]:.5f}") diff --git a/autodE/source/doc/common/OH_PES_unrelaxed.png b/autodE/source/doc/common/OH_PES_unrelaxed.png new file mode 100644 index 0000000000000000000000000000000000000000..c2facda47e6b9e557245b0579ca9b07a4f7701d8 --- /dev/null +++ b/autodE/source/doc/common/OH_PES_unrelaxed.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c34ff391fe9665ed03c14e39fbdd2115cdbbe5cc08daf65fd973536a79b7ad01 +size 129868 diff --git a/autodE/source/doc/common/OH_PES_unrelaxed.py b/autodE/source/doc/common/OH_PES_unrelaxed.py new file mode 100644 index 0000000000000000000000000000000000000000..b983f6a1be3d1b8f707265389f6c7deee133684a --- /dev/null +++ b/autodE/source/doc/common/OH_PES_unrelaxed.py @@ -0,0 +1,19 @@ +import autode as ade +import matplotlib.pyplot as plt + +water = ade.Molecule(name="H2O", smiles="O") +# water.atoms = [[O, x, y, z], [H, x', y', z'], [H, x'', y'', z'']] + +# Initialise the unrelaxed potential energy surface over the +# O-H bond from 0.65 Å to 2.0 Å in 20 steps +pes = ade.pes.UnRelaxedPES1D(species=water, rs={(0, 1): (0.65, 2.0, 20)}) + +# Calculate the surface using the XTB tight-binding DFT method +pes.calculate(method=ade.methods.XTB()) + +# Finally, plot the surface using matplotlib +plt.plot(pes.r1, pes.relative_energies.to("kcal mol-1"), marker="o") + +plt.ylabel("ΔE / kcal mol$^{-1}$") +plt.xlabel("r / Å") +plt.savefig("OH_PES_unrelaxed.png", dpi=400) diff --git a/autodE/source/doc/common/OH_PES_unrelaxed_DFT.png b/autodE/source/doc/common/OH_PES_unrelaxed_DFT.png new file mode 100644 index 0000000000000000000000000000000000000000..5a3678ce1c643c0a914a908810c45acce6181dee --- /dev/null +++ b/autodE/source/doc/common/OH_PES_unrelaxed_DFT.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ed09a7ce4c30b47feb6e976371edb15d0ee7e71c42981285827dcd57348a5c6a +size 217475 diff --git a/autodE/source/doc/common/OH_PES_unrelaxed_DFT.py b/autodE/source/doc/common/OH_PES_unrelaxed_DFT.py new file mode 100644 index 0000000000000000000000000000000000000000..386e337a17973f571f40599d48b33a3c99c86348 --- /dev/null +++ b/autodE/source/doc/common/OH_PES_unrelaxed_DFT.py @@ -0,0 +1,25 @@ +import autode as ade +import matplotlib.pyplot as plt + +# Initialise the PES over the O-H bond 0.65 -> 2.0 Å +pes = ade.pes.UnRelaxedPES1D( + species=ade.Molecule(name="H2O", smiles="O"), rs={(0, 1): (0.65, 2.0, 20)} +) + +# For the three different DFT functionals calculate the PES and plot the line +for functional in ("PBE", "PBE0", "B3LYP"): + pes.calculate(method=ade.methods.ORCA(), keywords=[functional, "def2-SVP"]) + + plt.plot( + pes.r1, + pes.relative_energies.to("kcal mol-1"), + marker="o", + label=functional, + ) + +# Add labels to the plot and save the figure +plt.ylabel("ΔE / kcal mol$^{-1}$") +plt.xlabel("r / Å") +plt.legend() +plt.tight_layout() +plt.savefig("OH_PES_unrelaxed_DFT.png", dpi=400) diff --git a/autodE/source/doc/common/XY_bde_XTB.png b/autodE/source/doc/common/XY_bde_XTB.png new file mode 100644 index 0000000000000000000000000000000000000000..9d333a9e50c94fb64fe24a1c1cd5c3f859f5a978 --- /dev/null +++ b/autodE/source/doc/common/XY_bde_XTB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:45cd1b278dc9057bf301a80d01de3c27bf78664645420375d35b951b1406916a +size 424461 diff --git a/autodE/source/doc/common/XY_bde_XTB.py b/autodE/source/doc/common/XY_bde_XTB.py new file mode 100644 index 0000000000000000000000000000000000000000..678a301dbaa6cab9fd5e5a0614e95cf2646cb527 --- /dev/null +++ b/autodE/source/doc/common/XY_bde_XTB.py @@ -0,0 +1,74 @@ +import autode as ade +import matplotlib.pyplot as plt +import numpy as np + +ade.Config.n_cores = 2 +xtb = ade.methods.XTB() + + +def bde_curve(smiles): + """Bond dissociation curve for a molecule given a SMILES string""" + + mol = ade.Molecule(smiles=smiles) + mol.optimise(method=ade.methods.XTB()) + + energies = [mol.energy] + rs = [mol.distance(0, 1)] + + for i in range(12): + r = rs[-1] + 0.2 + mol.constraints.distance = {(0, 1), r} + + calc = ade.Calculation( + name=f"{smiles}_step{i}", + molecule=mol, + method=xtb, + keywords=xtb.keywords.opt, + ) + try: + mol.optimise(calc=calc) + except: # optimising long bond lengths can break! + pass + + # Only admit sensible energies into the set + if mol.energy is None or mol.energy > 0.3 + energies[0]: + continue + + energies.append(mol.energy) + rs.append(r) + + rel_energies = 627.5 * (np.array(energies) - min(energies)) # kcal mol-1 + return rs, rel_energies + + +if __name__ == "__main__": + fig, (ax1, ax2) = plt.subplots(ncols=2, figsize=(8, 4)) + + l_elements = ["C", "N", "O", "F", "Cl", "[H]", "P", "S", "Br"] + r_elements = ["C", "N", "O", "F", "Cl", "[H]", "P", "S", "Br"] + + # Factors by which the initial bond length is multiplied when ∆E = 0.9D_0 + bde_multipliers = [] + + for i, ele_l in enumerate(l_elements): + for j, ele_r in enumerate(r_elements): + if i < j: + continue + + dists, rel_es = bde_curve(smiles=f"{ele_l}{ele_r}") + ax1.plot(dists, rel_es, marker="o", label=f"{ele_l}{ele_r}") + + dist_90p_idx = int(np.argmin(np.abs(rel_es - 0.75 * rel_es[-1]))) + bde_multiplier = dists[dist_90p_idx] / dists[0] + bde_multipliers.append(bde_multiplier) + + ax2.scatter(np.arange(len(bde_multipliers)), bde_multipliers, c="k") + ax2.set_ylabel("$r$(∆E = 0.75D$_0$) / r$_0$") + ax2.set_ylim(0, 5) + + ax1.set_xlabel("$r$ / Å") + ax1.set_ylabel("∆$E$ / kcal mol$^{-1}$") + ax1.legend(prop={"size": 3}) + + plt.tight_layout() + plt.savefig("X-Y_bde.png", dpi=300) diff --git a/autodE/source/doc/common/adapt_surface_sn2.png b/autodE/source/doc/common/adapt_surface_sn2.png new file mode 100644 index 0000000000000000000000000000000000000000..f0970268591698cec06ccd7f8e306f63ad344b26 --- /dev/null +++ b/autodE/source/doc/common/adapt_surface_sn2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:20ce69540edd3ff1f32ff96265478a72feb3e7af26c0b84be62ed7597537c40c +size 285464 diff --git a/autodE/source/doc/common/claisen_cineb.py b/autodE/source/doc/common/claisen_cineb.py new file mode 100644 index 0000000000000000000000000000000000000000..443c4d25a51f99d7c673a113c9641fd0f131a4ac --- /dev/null +++ b/autodE/source/doc/common/claisen_cineb.py @@ -0,0 +1,14 @@ +import autode as ade + +reac = ade.Reactant("claisen_r.xyz") +prod = ade.Product("claisen_p.xyz") + +# Create an 8 image nudged elastic band with intermediate images interpolated +# from the final points, thus they must be structurally similar +neb = ade.CINEB.from_end_points(reac, prod, num=8) +# minimise with XTB +neb.calculate(method=ade.methods.XTB(), n_cores=4) + +# print the geometry of the peak species +print("Found a peak: ", neb.images.contains_peak) +neb.peak_species.print_xyz_file(filename="peak.xyz") diff --git a/autodE/source/doc/common/claisen_neb_optimised.png b/autodE/source/doc/common/claisen_neb_optimised.png new file mode 100644 index 0000000000000000000000000000000000000000..5271978765e4abb8cd6f098229de5665e0c957bd --- /dev/null +++ b/autodE/source/doc/common/claisen_neb_optimised.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d492826ffc52cf243fd162899dd47b38f44f65c085fca3d5c87273b016461f10 +size 194256 diff --git a/autodE/source/doc/common/claisen_p.xyz b/autodE/source/doc/common/claisen_p.xyz new file mode 100644 index 0000000000000000000000000000000000000000..345a113b34400caf0aeafb8b6e3918e6f25c7433 --- /dev/null +++ b/autodE/source/doc/common/claisen_p.xyz @@ -0,0 +1,22 @@ +20 +claisen_p +C -4.34571 0.63520 0.57310 +C -4.49777 -0.68069 0.34490 +C -3.23250 1.40679 0.03037 +C -2.22119 0.64743 -0.81975 +C -2.49978 -0.80349 -0.98985 +C -3.56127 -1.41597 -0.45542 +O -3.11541 2.59776 0.24431 +C -0.59568 3.13650 -1.35460 +H -2.24578 1.10277 -1.81941 +H -1.78329 -1.35547 -1.58358 +H -3.73211 -2.47147 -0.60583 +H -5.04256 1.18717 1.18701 +H -5.33408 -1.21816 0.77022 +C -0.15430 2.17960 -0.56309 +H -0.03493 4.04448 -1.49486 +H -1.53872 3.08831 -1.87085 +C -0.79076 0.85415 -0.27213 +H 0.79501 2.30610 -0.05582 +H -0.80710 0.70442 0.81159 +H -0.14130 0.07563 -0.68902 diff --git a/autodE/source/doc/common/claisen_peak.png b/autodE/source/doc/common/claisen_peak.png new file mode 100644 index 0000000000000000000000000000000000000000..d1051f6d5c576b4709db72a1bc164ad4b5eff9c1 Binary files /dev/null and b/autodE/source/doc/common/claisen_peak.png differ diff --git a/autodE/source/doc/common/claisen_r.xyz b/autodE/source/doc/common/claisen_r.xyz new file mode 100644 index 0000000000000000000000000000000000000000..b9bcafe498d179e81f0d59660f6940abf0d6fd00 --- /dev/null +++ b/autodE/source/doc/common/claisen_r.xyz @@ -0,0 +1,22 @@ +20 +claisen_r +C -3.98726 0.74520 0.45281 +C -4.50106 -0.55204 0.38563 +C -2.98263 1.15687 -0.42991 +C -2.51083 0.25840 -1.40799 +C -3.03021 -1.03906 -1.47415 +C -4.01895 -1.44546 -0.57369 +O -2.47596 2.40755 -0.27307 +C -1.33638 2.94073 -0.85085 +H -1.73709 0.54678 -2.10649 +H -2.65792 -1.73268 -2.21661 +H -4.41017 -2.45344 -0.61689 +H -4.35505 1.42777 1.20883 +H -5.26464 -0.86554 1.08531 +C -0.04683 2.27939 -0.39973 +H -1.28052 4.00673 -0.54623 +H -1.40915 2.91149 -1.95905 +C 0.04395 1.20681 0.40079 +H 0.87953 2.71662 -0.75770 +H -0.81785 0.69435 0.80686 +H 1.01980 0.81060 0.65942 diff --git a/autodE/source/doc/common/conformers.png b/autodE/source/doc/common/conformers.png new file mode 100644 index 0000000000000000000000000000000000000000..2dbd1fc5bc100a4d8395f02b6bcb58bd15a4a8b0 --- /dev/null +++ b/autodE/source/doc/common/conformers.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c762c59989eed2c04c041aaffb5c3911d449738760e7eb0a58985ae031052312 +size 615466 diff --git a/autodE/source/doc/common/curtius.png b/autodE/source/doc/common/curtius.png new file mode 100644 index 0000000000000000000000000000000000000000..2a4b0f9d3ee7c41675f68dd03bdd0470a5478a1f --- /dev/null +++ b/autodE/source/doc/common/curtius.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:746d4a86d1865067fb5d833ddbda648a5fcf7a8a3049dd9270ac847e040815c4 +size 356351 diff --git a/autodE/source/doc/common/curtius.py b/autodE/source/doc/common/curtius.py new file mode 100644 index 0000000000000000000000000000000000000000..cb9431c41ed798827da7880db602cf0430bd1445 --- /dev/null +++ b/autodE/source/doc/common/curtius.py @@ -0,0 +1,7 @@ +import autode as ade + +ade.Config.n_cores = 8 + +rxn = ade.Reaction("CC(N=[N+]=[N-])=O>>CN=C=O.N#N") +rxn.locate_transition_state() +rxn.ts.print_xyz_file(filename="ts.xyz") diff --git a/autodE/source/doc/common/curtius_ts.png b/autodE/source/doc/common/curtius_ts.png new file mode 100644 index 0000000000000000000000000000000000000000..da331ad056895e16782b3daabde37fe3c9083058 --- /dev/null +++ b/autodE/source/doc/common/curtius_ts.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2805eae20642f2db020b0cffda5694a0ee8fbd87bab4f32bc535e499c65fbb5a +size 151149 diff --git a/autodE/source/doc/common/curtius_ts.xyz b/autodE/source/doc/common/curtius_ts.xyz new file mode 100644 index 0000000000000000000000000000000000000000..5682345d47cc32b88f568323485a76bfd1dd01bb --- /dev/null +++ b/autodE/source/doc/common/curtius_ts.xyz @@ -0,0 +1,11 @@ +9 + +C -1.39241 0.07121 0.43067 +C -0.04556 -0.33487 -0.30235 +N 0.68638 0.45200 0.41896 +N 2.38870 0.17768 -0.19102 +N 3.46715 0.37885 -0.11094 +O 0.02674 -1.13494 -1.20133 +H -1.32812 0.81952 1.22935 +H -2.03100 0.44587 -0.38050 +H -1.77188 -0.87532 0.83854 diff --git a/autodE/source/doc/common/cyclohexene.xyz b/autodE/source/doc/common/cyclohexene.xyz new file mode 100644 index 0000000000000000000000000000000000000000..d43e590ad851248b755597e0775a33a7d31b055f --- /dev/null +++ b/autodE/source/doc/common/cyclohexene.xyz @@ -0,0 +1,18 @@ +16 +Generated by autodE on: 2021-12-04. +C -1.25524 0.55843 -0.45127 +C -0.11901 1.52914 -0.34083 +C 1.12810 1.05947 -0.22343 +C 1.36167 -0.42098 -0.26242 +C 0.31173 -1.21999 0.53472 +C -1.08411 -0.56797 0.57151 +H -1.28068 0.12348 -1.46969 +H -2.22404 1.06103 -0.30930 +H -0.31910 2.60539 -0.34475 +H 1.98105 1.73907 -0.13515 +H 2.37438 -0.67767 0.08526 +H 1.32299 -0.74206 -1.32088 +H 0.25137 -2.23441 0.11110 +H 0.67503 -1.34778 1.56617 +H -1.86306 -1.33147 0.42039 +H -1.26117 -0.13357 1.56876 diff --git a/autodE/source/doc/common/diels_alder.png b/autodE/source/doc/common/diels_alder.png new file mode 100644 index 0000000000000000000000000000000000000000..8ef6ab4d10a9f03a6dd105a0f6f2d2f1a46a7405 --- /dev/null +++ b/autodE/source/doc/common/diels_alder.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6ab144412d7c3c73935089a6896a5328a310cad33c6dbabccb22091cd8d6bde1 +size 157753 diff --git a/autodE/source/doc/common/diels_alder_quickstart.png b/autodE/source/doc/common/diels_alder_quickstart.png new file mode 100644 index 0000000000000000000000000000000000000000..59838dbba1d72155ca3bb07c9d120a3dfd642e87 --- /dev/null +++ b/autodE/source/doc/common/diels_alder_quickstart.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e261ec18a555b948b3635e1e785771634fced3110937bf84bb01450c7618f630 +size 410955 diff --git a/autodE/source/doc/common/functionalisation.png b/autodE/source/doc/common/functionalisation.png new file mode 100644 index 0000000000000000000000000000000000000000..24e28da960899613fb66e9cfd2019da2521331ce --- /dev/null +++ b/autodE/source/doc/common/functionalisation.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0efeb33603871cf1e616d272516d7dbf463058445bab12e69e76913bee014cfd +size 258682 diff --git a/autodE/source/doc/common/logo.png b/autodE/source/doc/common/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..81d5b19a8d2dbcd71c299b411e8d144a340354c1 --- /dev/null +++ b/autodE/source/doc/common/logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:97280c3a9f5f96696f325970d1cd88313b7ea60673c34cc5e7e4a5114fe34ad9 +size 102627 diff --git a/autodE/source/doc/common/methane_molfunc.py b/autodE/source/doc/common/methane_molfunc.py new file mode 100644 index 0000000000000000000000000000000000000000..98120599c3e4e8cd3bc6e34e82c01ba07d173440 --- /dev/null +++ b/autodE/source/doc/common/methane_molfunc.py @@ -0,0 +1,17 @@ +from molfunc import CoreMolecule, CombinedMolecule + +fragments = { + "NMe2": "CN([Fr])C", + "NH2": "N[Fr]", + "OH": "O[Fr]", + "Me": "C[Fr]", + "F": "F[Fr]", +} + +methane = CoreMolecule(xyz_filename="CH4.xyz", atoms_to_del=[2]) + +for name, smiles in fragments.items(): + combined = CombinedMolecule( + core_mol=methane, frag_smiles=smiles, name=f"CH3_{name}" + ) + combined.print_xyz_file() diff --git a/autodE/source/doc/common/molfunc_functionalisation.png b/autodE/source/doc/common/molfunc_functionalisation.png new file mode 100644 index 0000000000000000000000000000000000000000..c92e2932e67d831d5cd037dcb28d77cf16a52cee --- /dev/null +++ b/autodE/source/doc/common/molfunc_functionalisation.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6dbc1ddbfa7868fa835ee10b83e218102e6121904811dc5d58132b210732f79f +size 282168 diff --git a/autodE/source/doc/common/na_h2o_3.py b/autodE/source/doc/common/na_h2o_3.py new file mode 100644 index 0000000000000000000000000000000000000000..c55a0859d655fdab63d36d1da23ea1088f80c2bd --- /dev/null +++ b/autodE/source/doc/common/na_h2o_3.py @@ -0,0 +1,15 @@ +import autode as ade + +ade.Config.n_cores = 8 + + +h2o = ade.Molecule(smiles="O") +na_ion = ade.Molecule(smiles="[Na+]") + +# Initialise the [Na(H2O)3]+ complex and search 'conformers' +na_h2o_3 = ade.NCIComplex(na_ion, h2o, h2o, h2o) +na_h2o_3.find_lowest_energy_conformer(allow_connectivity_changes=True) + +# Print .xyz files of all the generated conformers +for idx, conformer in enumerate(na_h2o_3.conformers): + conformer.print_xyz_file(filename=f"conf{idx}.xyz") diff --git a/autodE/source/doc/common/na_h2o_3_confomers.png b/autodE/source/doc/common/na_h2o_3_confomers.png new file mode 100644 index 0000000000000000000000000000000000000000..d1d57072fa9127d56cdd97006ad6fc02d5885371 --- /dev/null +++ b/autodE/source/doc/common/na_h2o_3_confomers.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4ee889b37c641cf6e668be9ca22a49473ecc6d2d45b64d7f881719c538b376e4 +size 729630 diff --git a/autodE/source/doc/common/nci_FF_example.py b/autodE/source/doc/common/nci_FF_example.py new file mode 100644 index 0000000000000000000000000000000000000000..75900c9a4372df22768fc1a92492ba5ed0947d6d --- /dev/null +++ b/autodE/source/doc/common/nci_FF_example.py @@ -0,0 +1,126 @@ +import autode as ade +import numpy as np + +from scipy.optimize import minimize +from scipy.spatial import distance_matrix + +ade.Config.max_num_complex_conformers = 10 + +ha_to_kcalmol = 627.509 +ang_to_a0 = 1.0 / 0.529177 + + +def rot_matrix(axis, theta): + """Compute the 3D rotation matrix using: + https://en.wikipedia.org/wiki/Euler–Rodrigues_formula""" + axis = np.asarray(axis) + axis /= np.linalg.norm(axis) + a = np.cos(theta / 2.0) + b, c, d = -axis * np.sin(theta / 2.0) + aa, bb, cc, dd = a * a, b * b, c * c, d * d + bc, ad, ac, ab, bd, cd = b * c, a * d, a * c, a * b, b * d, c * d + return np.array( + [ + [aa + bb - cc - dd, 2 * (bc + ad), 2 * (bd - ac)], + [2 * (bc - ad), aa + cc - bb - dd, 2 * (cd + ab)], + [2 * (bd + ac), 2 * (cd - ab), aa + dd - bb - cc], + ] + ) + + +def rotation_translation( + x, atoms, fixed_idxs_, shift_idxs_, return_energy=True +): + """Apply a rotation and translation""" + + f_coords = np.array([atoms[i].coord for i in fixed_idxs_], copy=True) + f_charges = np.array([atoms[i].charge for i in fixed_idxs_]) + f_vdw = np.array([atoms[i].vdw for i in fixed_idxs_]) + + s_coords = np.array([atoms[i].coord for i in shift_idxs_], copy=True) + # Shift to ~COM + com = np.average(s_coords, axis=0) + s_coords -= com + # Apply the roation + s_coords = rot_matrix(axis=x[:3], theta=x[3]).dot(s_coords.T).T + # Shift back, and apply the translation + s_coords += com + x[4:] + + s_charges = np.array([atoms[i].charge for i in shift_idxs_]) + s_vdw = np.array([atoms[i].vdw for i in shift_idxs_]) + + dist_mat = distance_matrix(f_coords, s_coords) + + # Matrix with the pairwise additions of the vdW radii + sum_vdw_radii = np.add.outer(f_vdw, s_vdw) + + # Magic numbers derived from fitting potentials to noble gas dimers and + # plotting against the sum of vdw radii + b_mat = 0.083214 * sum_vdw_radii - 0.003768 + a_mat = 11.576415 * (0.175541 * sum_vdw_radii + 0.316642) + exponent_mat = -(dist_mat / b_mat) + a_mat + + repulsion = np.sum(np.exp(exponent_mat)) + + # Charges are already in units of e + prod_charge_mat = np.outer(f_charges, s_charges) + + # Compute the pairwise iteration energies as V = q1 q2 / r in atomic units + energy_mat = prod_charge_mat / (ang_to_a0 * dist_mat) + electrostatic = ha_to_kcalmol * np.sum(energy_mat) / 2.0 + + if return_energy: + return repulsion + electrostatic + + # Set the new coordinated of the shifted atoms, if required + n = 0 + for i, atom in enumerate(atoms): + if i in shift_idxs_: + atom.coord = s_coords[n] + n += 1 + + return atoms + + +def set_charges_vdw(species): + """Calculate the partial atomic charges to atoms with XTB""" + calc = ade.Calculation( + name="tmp", + molecule=species, + method=ade.methods.XTB(), + keywords=ade.SinglePointKeywords(), + ) + calc.run() + + for i, atom in enumerate(species.atoms): + atom.charge = atom.partial_charge + atom.vdw = float(atom.vdw_radius) + + return None + + +if __name__ == "__main__": + h2o = ade.Molecule(smiles="O") + set_charges_vdw(h2o) + + water_dimer = ade.species.NCIComplex(h2o, h2o) + water_dimer._generate_conformers() + + shift_idxs = water_dimer.atom_indexes(mol_index=1) + fixed_idxs = [i for i in range(water_dimer.n_atoms) if i not in shift_idxs] + + for conformer in water_dimer.conformers: + opt = minimize( + rotation_translation, + x0=np.random.random(size=7), + args=(conformer.atoms, fixed_idxs, shift_idxs), + method="L-BFGS-B", + tol=0.01, + ) + print(opt) + + conformer.energy = opt.fun + conformer.atoms = rotation_translation( + opt.x, conformer.atoms, fixed_idxs, shift_idxs, return_energy=False + ) + conformer.print_xyz_file() diff --git a/autodE/source/doc/common/opt_convergence_3500_ORCA.png b/autodE/source/doc/common/opt_convergence_3500_ORCA.png new file mode 100644 index 0000000000000000000000000000000000000000..8dc4c07e8d1303d2fb9f9bfd8ed26d25b78cfb7e --- /dev/null +++ b/autodE/source/doc/common/opt_convergence_3500_ORCA.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:df31e39bb0d609b8122caad5ac8c5c0d880612070bef802ee0a03e54d84850a1 +size 1489080 diff --git a/autodE/source/doc/common/reaction_simple_uml.svg b/autodE/source/doc/common/reaction_simple_uml.svg new file mode 100644 index 0000000000000000000000000000000000000000..58c9b0e3034c53dc63ff7b1b6101d735fe615c93 --- /dev/null +++ b/autodE/source/doc/common/reaction_simple_uml.svg @@ -0,0 +1,282 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Reaction + + + + + + +- + + +reacs + + + + +- + + +prods + + + + +- + + +tss + + + + + + + + +- + + +reactant + + + + +- + + +product + + + + +- + + +ts + + + + + + + + +- + + +delta( ) + + + + +- + + +locate_transition_state( ) + + + + +- + + +calculate_reaction_profile( ) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +calculate_reaction_profile + + + + + + +- + + +find_lowest_energy_conformers( ) + + + + +- + + +locate_transition_state( ) + + + + +- + + +find_lowest_energy_ts_conformer( ) + + + + +- + + +[calculate_complexes( )] + + + + +- + + +[calculate_thermochemical_cont( )] + + + + +- + + + calculate_single_points( ) + + + + +- + + + print_output( ) + + + + + + + + + + + +Attribute + + + + + + + + + +Property + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Method + + + + diff --git a/autodE/source/doc/common/rmsd.py b/autodE/source/doc/common/rmsd.py new file mode 100644 index 0000000000000000000000000000000000000000..006d8242e8eacbe3d72b2120be5e3181eba2c872 --- /dev/null +++ b/autodE/source/doc/common/rmsd.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +from autode.species import Species +from autode.geom import calc_rmsd +from autode.input_output import xyz_file_to_atoms +from shutil import copyfile +import os +import argparse + +folder_name = "unique_conformers" + + +def get_args(): + parser = argparse.ArgumentParser() + parser.add_argument( + "filenames", action="store", nargs="+", help=".xyz files to compare" + ) + + parser.add_argument( + "-t", + action="store", + default=1.0, + type=float, + help="RMSD threshold (Å)", + ) + + parser.add_argument( + "-oh", + action="store_true", + default=False, + help="Only compare heavy atoms", + ) + + return parser.parse_args() + + +def get_molecules_no_hydrogens(molecules): + """Remove all hydrogen atoms from a list of molecules""" + + no_h_molecules = [] + + for molecule in molecules: + molecule.atoms = [atom for atom in molecule.atoms if atom.label != "H"] + + no_h_molecules.append(molecule) + + return no_h_molecules + + +def get_and_copy_unique_confs(xyz_filenames, only_heavy_atoms, threshold_rmsd): + """For each xyz file generate a species and copy it to a folder if it is + unique based on an RMSD threshold""" + + molecules = [ + Species( + name=fn.rstrip(".xyz"), + atoms=xyz_file_to_atoms(fn), + charge=0, + mult=1, + ) + for fn in xyz_filenames + ] + + if only_heavy_atoms: + molecules = get_molecules_no_hydrogens(molecules) + + unique_mol_ids = [] + + for i in range(len(molecules)): + mol = molecules[i] + + is_unique = True + + for j in unique_mol_ids: + rmsd = calc_rmsd(mol.coordinates, molecules[j].coordinates) + + if rmsd < threshold_rmsd: + is_unique = False + break + + if is_unique: + unique_mol_ids.append(i) + + print("Number of unique molecules = ", len(unique_mol_ids)) + + # Copy all the unique .xyz files to a new folder + if not os.path.exists(folder_name): + os.mkdir(folder_name) + + for i in unique_mol_ids: + xyz_filename = xyz_filenames[i] + copyfile(xyz_filename, os.path.join(folder_name, xyz_filename)) + + return None + + +if __name__ == "__main__": + args = get_args() + assert all(fn.endswith(".xyz") for fn in args.filenames) + + get_and_copy_unique_confs( + xyz_filenames=args.filenames, + only_heavy_atoms=args.oh, + threshold_rmsd=args.t, + ) diff --git a/autodE/source/doc/common/simple_uml.svg b/autodE/source/doc/common/simple_uml.svg new file mode 100644 index 0000000000000000000000000000000000000000..abb759bc91d54a0d528c904674dfb1fc2e57b9aa --- /dev/null +++ b/autodE/source/doc/common/simple_uml.svg @@ -0,0 +1,1697 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Class + + + + + + + + + + + + + + + + + + + + + + + + + +Attribute + + + + + + + + + + + + + + + + + +Property + + + + + + + + + + + + + + + + + +Method + + + + + + + + + + + + + + + + + + +Instance + + + + + + + + + + + + + + + + + + +Composition + + + + + + + + + + + + + + + + + + +Inheritance + + + + + + + + + +Reaction + + + + + + +- + + +reacs + + + + +- + + +prods + + + + +- + + +tss + + + + + + + + +- + + +reactant + + + + +- + + +product + + + + +- + + +ts + + + + + + + + +- + + +delta( ) + + + + +- + + +locate_transition_state( ) + + + + +- + + +calculate_reaction_profile( ) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Molecule + + + + + + +- + + +_init_smiles( ) + + + + + + + + + + + + + +List + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Complex + + + + + + +- + + +populate_conformers( ) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Species + + + + + + +- + + +name + + + + +- + + +graph + + + + +- + + +solvent + + + + +- + + +constraints + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +TransitionState + + + + + + +- + + +bond_rearrangement + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +AtomCollection + + + + + + +- + + +atoms + + + + + + + + + + + + + + + + + + + + + + + + + + + +Atoms + + + + + + + + + + + + + + + + + + + + + + + + + +Atom + + + + + + + + + + + + + + + +List + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +calculate_reaction_profile + + + + + + +- + + +find_lowest_energy_conformers( ) + + + + +- + + +locate_transition_state( ) + + + + +- + + +find_lowest_energy_ts_conformer( ) + + + + +- + + +[calculate_complexes( )] + + + + +- + + +[calculate_thermochemical_cont( )] + + + + +- + + + calculate_single_points( ) + + + + +- + + + print_output( ) + + + + + + + + +- + + +charge + + + + +- + + +mult + + + + +- + + +energy + + + + +- + + +solvent + + + + +- + + +conformers + + + + + + + + + + + +- + + +print_xyz_file( ) + + + + +- + + +single_point( ) + + + + +- + + +optimise( ) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +PotentialEnergy + + + + + + +- + + +to( ) + + + + + + + + + + + +- + + +units + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +TransitionStates + + + + + + +- + + +lowest_energy + + + + + + + + + + +List + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Solvent + + + + + + +- + + +dielectric + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +ImplicitSolvent + + + + + + +- + + +to_explicit( ) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +ExplicitSolvent + + + + + + + + + + +- + + +solvent_radius + + + + + + + + + + + + + + + + + + + + + +- + + +solvent_atom_idxs( ) + + + + +- + + +randomise_around( ) + + + + + + + + + + + +- + + +n_solvent_molecules + + + + + + + + + + + +- + + +n_atoms + + + + +- + + +weight/mass + + + + +- + + +centre_of_mass + + + + +- + + +coordinates + + + + + + + + + + + +- + + +distance( ) + + + + +- + + +angle( ) + + + + +- + + +dihedral( ) + + + + + + + + + + + +- + + +are_linear( ) + + + + +- + + +vector( ) + + + + + + + + + + + +- + + +coordinates + + + + + + + + + + + +- + + +coordinate + + + + +- + + +label/atomic_symbol + + + + + + + + + + + +- + + +atomic_number + + + + +- + + +group + + + + +- + + +period + + + + +- + + +mass + + + + +- + + +vdw_radius + + + + + + + + + + + +- + + +translate( ) + + + + +- + + +rotate( ) + + + + + + + + + + + +- + + +is_true_ts + + + + + + + + + + + +- + + +print_imag_vector( ) + + + + +- + + +find_lowest_energy_ + + + + + ts_conformer( ) + + + + + + + + + + + + + + + + + + + + + + + + + + + + +single_point + + + + + + +- + + +calc + + + + +- + + +calc.run( ) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Calculation + + + + + + +- + + +method + + + + +- + + +keywords + + + + +- + + +Input + + + + +- + + +output + + + + + + + + + + + +- + + +run( ) + + + + +- + + +get_[property]( ) + + + + + + + + + + + +- + + +terminated_normally + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Method + + + + + + +- + + +available + + + + + + + + + + + +- + + +path + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Keywords + + + + + + +- + + +basis_set + + + + +- + + +functional + + + + +- + + +dispersion + + + + +- + + +wf_method + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Keyword + + + + +List + + + + + + +- + + +name + + + + +- + + +doi_list + + + + + + + + + diff --git a/autodE/source/doc/common/sn2_image.png b/autodE/source/doc/common/sn2_image.png new file mode 100644 index 0000000000000000000000000000000000000000..9fe940df0fe75ea3360e9d68ca13d4246cedd657 --- /dev/null +++ b/autodE/source/doc/common/sn2_image.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e70745f3d08e431056e0b5bea6aefe1ad0b2f99e0b27908da57bbc1d6e6a549c +size 123672 diff --git a/autodE/source/doc/common/sn2_reaction_profile.png b/autodE/source/doc/common/sn2_reaction_profile.png new file mode 100644 index 0000000000000000000000000000000000000000..bf0adf87d91a7138c7722b1bbc57f4ccc6cecee4 Binary files /dev/null and b/autodE/source/doc/common/sn2_reaction_profile.png differ diff --git a/autodE/source/doc/common/translation_rotation.png b/autodE/source/doc/common/translation_rotation.png new file mode 100644 index 0000000000000000000000000000000000000000..92a0683b9a76b45e0a7c6934d2140dfbe08c18c4 Binary files /dev/null and b/autodE/source/doc/common/translation_rotation.png differ diff --git a/autodE/source/doc/common/vaskas.png b/autodE/source/doc/common/vaskas.png new file mode 100644 index 0000000000000000000000000000000000000000..72a8e324cdf55c1734329b20ad5b0731ac9dc5da --- /dev/null +++ b/autodE/source/doc/common/vaskas.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ccb6c139c431e48c08d0169cebd90bda1c3fce0bca831f71fe0988ad9a0a52d4 +size 389870 diff --git a/autodE/source/doc/common/vaskas.xyz b/autodE/source/doc/common/vaskas.xyz new file mode 100644 index 0000000000000000000000000000000000000000..72dafc6c42755e90bd375b27271afbd60f22d5e3 --- /dev/null +++ b/autodE/source/doc/common/vaskas.xyz @@ -0,0 +1,74 @@ +72 + +Ir -0.73544159982476 0.12098836116547 0.44386509512919 +Cl -0.26409903742733 -0.60165975286909 2.56484501069948 +P -2.85727933656398 -0.07578774901201 1.31676841182216 +P 1.53391907041356 0.19906228534987 -0.00606503388297 +C -1.18638854880250 0.64759461355397 -1.25604221038795 +C -3.17780235693313 -1.73070751800558 1.99190965045579 +C -3.33239042412122 -2.00292833328008 3.34401114052303 +C -3.49950663192716 -3.31069750293758 3.76667642368250 +C -3.50434321316569 -4.34762443245515 2.84921249075803 +C -3.32700482068606 -4.08146841572088 1.49998525820754 +C -3.15682611323671 -2.77880505874603 1.07370901209989 +C -3.25776432229881 1.07535102895104 2.66849573954444 +C -4.49874218298346 1.02354809650786 3.29752485160896 +C -4.79853940209310 1.92037151942467 4.30524355132245 +C -3.87051385614113 2.87933037797510 4.68204549113749 +C -2.64334740480622 2.94607056796840 4.04435253800970 +C -2.33841283888876 2.04828184865113 3.03671907510148 +C -4.34385640337987 0.22604242931804 0.28969360188255 +C -4.34210007476491 1.41272689931872 -0.43754735523543 +C -5.42827534696541 1.75987141196520 -1.21612230739678 +C -6.52870322984986 0.91889531323004 -1.27841398464028 +C -6.53691774793128 -0.26251130417738 -0.55753849487638 +C -5.45062969040796 -0.60997556243503 0.22783865736632 +C 2.66221856357580 0.45094476572467 1.39942103537578 +C 2.91030619086489 -0.62699185830805 2.24617627401349 +C 3.69025663375748 -0.45774203362183 3.37359071747685 +C 4.22302960145204 0.78713747613511 3.66914242736448 +C 3.96938587904016 1.86370721831231 2.83613859174103 +C 3.18471992296013 1.70140934292746 1.70776131022812 +C 2.21688441829931 -1.31073193681513 -0.76282127302737 +C 3.57195523827999 -1.62796811516489 -0.71905418152402 +C 4.03201571920914 -2.77807939652278 -1.33424163640377 +C 3.14789133927478 -3.61913015908165 -1.99138505688663 +C 1.79761592420900 -3.31199342793682 -2.03023387533447 +C 1.33379139554345 -2.16169232385300 -1.41795299608676 +C 2.07001708016822 1.51426050125056 -1.15742630094809 +C 3.10824491125561 1.38063651815810 -2.06944372553389 +C 3.44946448233907 2.44299208271913 -2.88906997086943 +C 2.76365399529961 3.64255535910443 -2.80234430428397 +C 1.72331026557136 3.78055261606645 -1.89707752879017 +C 1.37565844434917 2.71927410099497 -1.08366330872567 +O -1.46258151401266 0.96103077291573 -2.34144421285371 +H -3.30555104518103 -1.19875885108254 4.06535029441342 +H -3.61889302880866 -3.52098315470210 4.81979872994647 +H -3.63406256043530 -5.36584834837252 3.18606257106957 +H -3.31399002483738 -4.89149619256230 0.78497385531597 +H -3.00134741521492 -2.55349864419924 0.02489597476366 +H -5.22424565633840 0.28034379005954 2.99378873521908 +H -5.75855924594462 1.87419092616843 4.79896164228223 +H -4.10816509166033 3.57661137424306 5.47218052688367 +H -1.92320005137725 3.69742506311396 4.33391150907485 +H -1.37888616752157 2.08407235164002 2.53583168577421 +H -3.47124127475096 2.05434333874185 -0.38413933609112 +H -5.41849278610192 2.68168632617471 -1.77855831778716 +H -7.37840004767592 1.18396269799009 -1.89014000945226 +H -7.39419292014992 -0.91865060092023 -0.60518988277618 +H -5.46653270711943 -1.53481937293159 0.78780375360490 +H 2.48461604753393 -1.59389384168336 2.01684685133682 +H 3.87768059547040 -1.29633925469734 4.02827860295262 +H 4.83127738269785 0.91864894879073 4.55217698836007 +H 4.37959596250994 2.83604506892846 3.06849853769122 +H 2.98084879122731 2.54551898272408 1.06325704677837 +H 4.26177617666582 -0.98041780500066 -0.19420486340699 +H 5.08437139550046 -3.02072853064547 -1.29956989775111 +H 3.51270557616182 -4.51719013168261 -2.46778785343716 +H 1.10746803384021 -3.97151952190545 -2.53540569750153 +H 0.27933135075731 -1.90791417846626 -1.43048170599943 +H 3.64421974795553 0.44635894006563 -2.15052565045629 +H 4.25418152950334 2.33189593003872 -3.60125186600363 +H 3.03426820993839 4.46719169328063 -3.44504105797509 +H 1.18115277692910 4.71244000382605 -1.83257422666500 +H 0.55103346777037 2.80240236631979 -0.38404553802081 diff --git a/autodE/source/doc/common/vaskas_conformers.png b/autodE/source/doc/common/vaskas_conformers.png new file mode 100644 index 0000000000000000000000000000000000000000..2d3ebe35dddc4e5c387d62bdfee7a6b50fb3c298 --- /dev/null +++ b/autodE/source/doc/common/vaskas_conformers.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b1f2dda5f9d2f9a332c4fe7df13775185d6c8ec0c03c4d1643ac99e6f394bfdf +size 1040383 diff --git a/autodE/source/doc/common/vaskas_conformers.py b/autodE/source/doc/common/vaskas_conformers.py new file mode 100644 index 0000000000000000000000000000000000000000..9f2689c80ec13da82a301281d3db614111cc9ea9 --- /dev/null +++ b/autodE/source/doc/common/vaskas_conformers.py @@ -0,0 +1,28 @@ +import autode as ade +from autode.conformers import conf_gen, Conformer + +# Initialise the complex from a .xyz file containing a square planar structure +vaskas = ade.Molecule("vaskas.xyz") + +# Set up some distance constraints where the keys are the atom indexes and +# the value the distance in Å. Fixing the Cl-P, Cl-P and Cl-C(=O) distances +# enforces a square planar geometry +distance_constraints = { + (1, 2): vaskas.distance(1, 2), + (1, 3): vaskas.distance(1, 3), + (1, 4): vaskas.distance(1, 4), +} + +# Generate 5 conformers +for n in range(5): + # Apply random displacements to each atom and minimise under a bonded + + # repulsive forcefield including the distance constraints + atoms = conf_gen.get_simanl_atoms( + species=vaskas, dist_consts=distance_constraints, conf_n=n + ) + + # Generate a conformer from these atoms then optimise with XTB + conformer = Conformer(name=f"vaskas_conf{n}", atoms=atoms) + + conformer.optimise(method=ade.methods.XTB()) + conformer.print_xyz_file() diff --git a/autodE/source/doc/common/water.png b/autodE/source/doc/common/water.png new file mode 100644 index 0000000000000000000000000000000000000000..405cdfe18336dad46ed1d493d50a754e75787ded Binary files /dev/null and b/autodE/source/doc/common/water.png differ diff --git a/autodE/source/doc/common/water_opt_energy.png b/autodE/source/doc/common/water_opt_energy.png new file mode 100644 index 0000000000000000000000000000000000000000..8611cdae13410219db5278cc3eacfa5ca48b9f9d --- /dev/null +++ b/autodE/source/doc/common/water_opt_energy.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:896a9015fae0a4e53fc01035725cbe29718c29d0c8e0941e8f9dcb9e83e3bcf2 +size 130791 diff --git a/autodE/source/doc/common/water_shift.png b/autodE/source/doc/common/water_shift.png new file mode 100644 index 0000000000000000000000000000000000000000..edbe3b0c342e960a363ef3b49fc6f3db3817ed65 --- /dev/null +++ b/autodE/source/doc/common/water_shift.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:23c36c8ffcde95cd62a1d81c3817e8b5cd7ad789a4ba21cc90f84cad50f800b5 +size 131186 diff --git a/autodE/source/doc/common/water_trimer.png b/autodE/source/doc/common/water_trimer.png new file mode 100644 index 0000000000000000000000000000000000000000..a352f1bb15069991cb25a46ffc17ed21f3323f74 --- /dev/null +++ b/autodE/source/doc/common/water_trimer.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7d527b28bf9a99fe468d5027a42f6aa807307408baae3d9f81ce397c8a3128aa +size 217716 diff --git a/autodE/source/doc/common/water_trimer.py b/autodE/source/doc/common/water_trimer.py new file mode 100644 index 0000000000000000000000000000000000000000..75664241382cd33f4b3c4052732c3f5ffa168b63 --- /dev/null +++ b/autodE/source/doc/common/water_trimer.py @@ -0,0 +1,20 @@ +import autode as ade + +xtb = ade.methods.XTB() + +# Number of points on the surface of the sphere for each component +ade.Config.num_complex_sphere_points = 5 # N_s + +# and the number of rotations to perform per point on the sphere +ade.Config.num_complex_random_rotations = 3 # N_r + +# Total number of conformers ~(N_s × N_r)^(N-1) for N molecules => ~225 + +# Make a water molecule and optimise at the XTB level +water = ade.Molecule(name="water", smiles="O") +water.optimise(method=xtb) + +# Make the NCI complex and find the lowest energy structure +trimer = ade.NCIComplex(water, water, water, name="water_trimer") +trimer.find_lowest_energy_conformer(lmethod=xtb) +trimer.print_xyz_file() diff --git a/autodE/source/doc/common/water_trimer_expl.png b/autodE/source/doc/common/water_trimer_expl.png new file mode 100644 index 0000000000000000000000000000000000000000..250bcc09f6ed9cc978bd779ef49573bd18b50bae --- /dev/null +++ b/autodE/source/doc/common/water_trimer_expl.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3bc66ea2127cf5339e1e5b93bbe12f1f2eafea6c2bbc4ec2cd8f1e6c642b85b6 +size 1081032 diff --git a/autodE/source/doc/conf.py b/autodE/source/doc/conf.py new file mode 100644 index 0000000000000000000000000000000000000000..660a9165ddf9d75da39337af4a9555e4ad7e81db --- /dev/null +++ b/autodE/source/doc/conf.py @@ -0,0 +1,49 @@ +# -- Project information ----------------------------------------------------- + +project = "autodE" +copyright = "2020-2021, Tom Young, Joseph Silcock" +author = "Tom Young, Joseph Silcock" + +import autode + +# The full version, including alpha/beta/rc tags +version = autode.__version__ + + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = ["sphinx.ext.autodoc", "sphinx.ext.napoleon"] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ["_templates"] + +autodoc_default_options = { + "members": True, + "special-members": "__init__", +} + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +# The suffix of source filenames. +source_suffix = ".rst" + +# The master toctree document. +master_doc = "index" + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = "sphinx_book_theme" + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ["_build/html/_static"] diff --git a/autodE/source/doc/config.rst b/autodE/source/doc/config.rst new file mode 100644 index 0000000000000000000000000000000000000000..ed696538146e39c39638844c40b36d43be1dcf3a --- /dev/null +++ b/autodE/source/doc/config.rst @@ -0,0 +1,216 @@ +Configuration +============= + +Configuration is handled with :code:`ade.Config` and can be modified for full +customization of the calculations. By default, high-level optimisations are +performed at PBE0-D3BJ/def2-SVP and single points at PBE0-D3BJ/def2-TZVP. + + +Calculations +------------ + +General +******* + +The high-level electronic structure code defaults to the first available +from {ORCA, Gaussian09, Gaussian16, NWChem, QChem} and the low-level from +{XTB, MOPAC}. To select Gaussian09 as the high-level method: + +.. code-block:: python + + >>> import autode as ade + >>> ade.Config.hcode = 'g09' + +Similarly, with the low-level: + +.. code-block:: python + + >>> ade.Config.lcode = 'MOPAC' + + +To set the number of cores available and the memory per core (in MB), to use a maximum +of 32 GB for the whole calculation: + +.. code-block:: python + + >>> ade.Config.n_cores = 8 + >>> ade.Config.max_core = 4000 + +------------ + +Keywords +******** + +**autodE** uses wrappers around common keywords used in QM calculations to allow +easy setting of e.g. a DFT functional. + + +.. code-block:: python + + >>> kwds = ade.Config.ORCA.keywords.sp + >>> kwds.functional + Functional(pbe0) + + +To modify the functional for single point energies, in ORCA: + + +.. code-block:: python + + >>> kwds.functional = 'B3LYP' + + +Alternatively, reassign to a whole new set of keywords: + + +.. code-block:: python + + >>> ade.Config.ORCA.keywords.sp = ['SP', 'B3LYP', 'def2-TZVP'] + + +To add diffuse functions with the *ma* scheme to the def2-SVP default +basis set for optimisations: + +.. code-block:: python + + >>> ade.Config.ORCA.keywords.set_opt_basis_set('ma-def2-SVP') + + +.. note:: + `set_opt_basis_set` sets the basis set in keywords.grad, keywords.opt_ts + keywords.opt, keywords.low_opt and keywords.hess. + +------------ + +Temporary configuration +********************** + +It is also possible to change configuration temporarily, by using the context +manager: + +.. code-block:: python + + >>> ade.Config.ORCA.keywords.opt.functional + Functional(pbe0) + >>> ade.Config.n_cores = 4 + >>> mol = ade.Molecule(smiles='CCO') + >>> with ade.temporary_config(): + >>> ade.Config.n_cores = 9 + >>> ade.Config.ORCA.keywords.opt.funcitonal = 'B3LYP' + >>> # this calculation will run with 9 cores and B3LYP functional + >>> mol.optimise(method=ade.methods.ORCA()) + >>> # when context manager returns previous state of Config is restored + >>> ade.Config.n_cores + 4 + >>> ade.Config.ORCA.keywords.opt.functional + Functional(pbe0) + +When the context manager exits, the previous state of the configuration is +restored. + +.. warning:: + Note that the context manager works by saving the state of the Config + when it is called and restoring the state when it exits. The way Python + handles object references means that any references taken before or inside + the context manager will become useless after it exits. Please see the example + below for details. + +.. code-block:: python + + >>> kwds = ade.Config.ORCA.keywords # kwds refers to an object inside Config.ORCA + >>> with temporary_config(): + ... kwds.opt.functional = 'B3LYP' + ... mol.optimise(method=ade.method.ORCA()) + ... # this works successfully + >>> # when context manager exits, all variables in Config are restored, including Config.ORCA + >>> # But kwds still refers to an object from old Config.ORCA + >>> kwds.opt.functional + Functional(B3LYP) + >>> ade.Config.ORCA.opt.functional # current config + Functional(pbe0) + +As seen from the above example, the variable :code:`kwds` is useless once the +context manager exits, and changes to :code:`kwds` no longer affects autodE. It is +best to always modify :code:`Config` directly. + +------------ + +XTB as a hmethod +**************** + +To use XTB as the *hmethod* for minima and TS optimisations within Gaussian use the `xtb-gaussian `_ wrapper +and some default options. Note that the string to call `xtb-gaussian` will need to be modified with the appropriate keywords for spin and solvent, e.g., "xtb-gaussian --alpb water". + +.. code-block:: python + + >>> kwds = ade.Config.G16.keywords + >>> kwds.sp = ["External='xtb-gaussian'", "IOp(3/5=30)"] + >>> kwds.low_opt = ["External='xtb-gaussian'", "Opt(Loose, NoMicro)", "IOp(3/5=30)"] + >>> kwds.opt = ["External='xtb-gaussian'", "Opt(NoMicro)", "IOp(3/5=30)"] + >>> kwds.opt_ts = ["External='xtb-gaussian'", "Opt(TS, CalcFC, NoEigenTest, MaxCycles=100, MaxStep=10, NoTrustUpdate, NoMicro)", "IOp(3/5=30)"] + >>> kwds.hess = ["External='xtb-gaussian'", "Freq", "Geom(Redundant)", "IOp(3/5=30)"] + >>> kwds.grad = ["External='xtb-gaussian'", 'Force(NoStep)', "IOp(3/5=30)"] + +To use XTB within ORCA copy the :code:`xtb` binary to the folder where the :code:`orca` binary is located and rename it :code:`otool_xtb`, then +set the keywords to use. For example + +.. code-block:: python + + >>> kwds = ade.Config.ORCA.keywords + >>> kwds.sp = ['SP', 'PBE0', 'def2-SVP'] + >>> kwds.opt = ['Opt', 'XTB2'] + >>> kwds.low_opt = ['Opt', 'XTB2'] + >>> kwds.hess = ['NumFreq', 'XTB2'] + >>> kwds.grad = ['EnGrad', 'XTB2'] + >>> kwds.opt_ts = ['OptTS', 'NumFreq', 'XTB2\n', + '%geom\n' + 'NumHess true\n' + 'Calc_Hess true\n' + 'Recalc_Hess 30\n' + 'Trust -0.1\n' + 'MaxIter 150\n' + 'end'] + + +------------ + +Other +***** + +See the `config file `_ +to see all the options. + +.. note:: + NWChem currently only supports solvents for DFT, other methods must not have + a solvent. + +------------ + +Logging +------- + +To set the logging level to one of {DEBUG, INFO, WARNING, ERROR} set the :code:`AUTODE_LOG_LEVEL` +environment variable, in bash:: + + $ export AUTODE_LOG_LEVEL=INFO + +To output the log to a file set e.g. *autode.log*:: + + $ export AUTODE_LOG_FILE=autode.log + +To log with timestamps and colours:: + + $ conda install coloredlogs + + +To set the logging level permanently add the above export statements to +your *bash_profile*. + +In case of Windows command prompt, use the set command to set environment +variables:: + + > set AUTODE_LOG_LEVEL=INFO + +For powershell, use :code:`$env`:: + + > $env:AUTODE_LOG_FILE = 'INFO' diff --git a/autodE/source/doc/dev/contributing.rst b/autodE/source/doc/dev/contributing.rst new file mode 100644 index 0000000000000000000000000000000000000000..6f393048c0246624978bc82641be7bfca9bbb12c --- /dev/null +++ b/autodE/source/doc/dev/contributing.rst @@ -0,0 +1,234 @@ +********************** +Contributing to autodE +********************** + +Contributions in any form are very much welcome. To make managing these +easier, we kindly ask that you follow the guidelines below. + + +Reporting a bug or suggesting changes/improvements +================================================== + +If you think you’ve found a bug in ``autode``, please let us know by +opening an issue on the main autodE GitHub repository. This will give +the autodE developers a chance to confirm the bug, investigate it and… +fix it! + +When reporting an issue, we suggest you follow the following template: + +-------------- + +- Operating System: (*e.g.* Ubuntu Linux 20.04) +- Python version: (*e.g* 3.9.4) +- autodE version: (*e.g.* 1.1.2) + +**Description**: *A one-line description of the bug.* + +**To Reproduce**: *The exact steps to reproduce the bug.* + +**Expected behaviour**: *A description of what you expected instead of +the observed behaviour.* + +-------------- + +When it comes to reporting bugs, **the more details the better**. Do not +hesitate to include command line output or screenshots as part of your +bug report. + +**An idea for a fix?**, feel free to describe it in your bug report. + +Contributing to the code +======================== + +Anybody is free to modify their own copy of autodE. We would also love +for you to contribute your changes back to the main repository, so that +other autodE users can benefit from them. + +The high-level view of the contributing workflow is: + +1. Fork the main repository (``duartegroup/autode``). +2. Implement changes and tests on your own fork on a given branch + (``/autode:``). +3. Create a new pull request on the main autodE repository from your + development branch onto ``autode:v1.X``, where `X` is the latest version. + +To learn more about GitHub forks and pull requests, read `Fork a +repo `__ +and `Creating a pull +request `__ +on the GitHub docs. + + +Guidelines for pull requests +---------------------------- + +First, install from source in a new environment and setup +`pre-commit `__ with:: + + $ git clone https://github.com/duartegroup/autodE.git && cd autodE + $ conda create -n ade python=3.9 --file requirements.txt --channel conda-forge + $ conda activate ade + $ pip install '.[dev]' + $ pre-commit install + + +Forks instead of branches +~~~~~~~~~~~~~~~~~~~~~~~~~ + +By default, contributors do not have permission to push branches to the +main autodE remote repository (``duartegroup/autode``). In most cases, +you should contribute to autodE through a pull request from a fork. + + +Several, smaller pull requests instead of one big PR +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Smaller pull requests (PRs) are reviewed faster, and more accurately. We +therefore encourage contributors to keep the set of changes within a +single pull request as small as possible. If your pull request modifies +more than 5 files, and/or several hundred lines of code, please break it down +into two or more pull requests. + + +Pull requests are more than code +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A pull request is difficult to review without a description of context +and motivation for the attached set of changes. Whenever you open a new +pull request, please include the following information: + +- **A title** that explicits the main change addressed by the pull + request. If you struggle to come out with a short and descriptive + title, this is an indication that your PR could (should?) be broken down + into smaller PRs. +- **A description** of the context and motivation for the attached set + of changes. *What is the current state of things?*, *Why should it be + changed?*. +- **A summary** of changes outlining the main points addressed by your + pull request, and how they relate to each other. Be sure to mention + any assumption(s) and/or choices that your made and alternative + design/implementaions that you considered. *What did you change or + add?* *How?*. *Anything you could have done differently? Why not?*. +- **Some advice for reviewers**. Indicate the parts of your changes on + which you would expect reviewers to focus their attention. These are + often parts that you are unsure about or code that may be difficult to + read. + + +Draft pull requests +~~~~~~~~~~~~~~~~~~~ + +Draft pull requests are a way to signal to other developers that you are +currently working on something and open for discussion about it. It’s +also providing the development team a glimpse of future code reviews. + +Look out for the “Convert to draft” button on the right hand side pane +when creating a pull request. + + +Style guidelines +---------------- + +Enforcing code style in contributions is key to maintain a consistent +code base. + + +Formatting +~~~~~~~~~~ + +autodE’s code loosely follows `the PEP8 +guidelines `__ for code +formatting. At the very least, we expect all contributors to use +formatters like `Black `__, +`autopep8 `__ or +`YAPF `__. + + +Naming +~~~~~~ + +1. Variables + + - Variable names should be ``snake_case``. + +2. Functions + + - Like variables, function names should be ``snake_case``. + + - Functions should always exit with an explicit ``return`` + statement, even if means ``return None``. + + - Functions should raise ``ValueError`` for invalid input. + + - Functions should return ``None`` rather than raising exceptions + upon *failure*. If something is irrevocably wrong they should raise a + ``RuntimeError``. + + - Docstrings are in Google format. See `Comments and + Docstrings `__ + in the Google Python Style Guide. + + - Functions should be type annotated: + + .. code:: python + + def _plot_reaction_profile_with_complexes(self, + units: 'autode.units.Unit', + free_energy: bool, + enthalpy: bool) -> None: + """Plot a reaction profile with the association complexes of R, P""" + + # ... + + To learn more about type annotations, read `Type Checking in + Python `__ + (realpython.com). + +3. Classes + + - Classes names should be ‘CamelCase’. + + +Custom types instead of primitive types +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For numbers and arrays that have associated units ``autode`` makes use of +custom floats (:code:`autode.values.Value`) and numpy arrays +(:code:`autode.values.ValueArray`). These have unit conversion with a :code:`to()` +method and enable comparison of quantities independent of units. + +.. code:: python + + >>> from autode.values import Distance + >>> r = Distance(1.0) + >>> r # Values have default units + Distance(1.0 Å) + >>> r.to('nm') # and to() methods + Distance(0.1 nm) + >>> r.to('nanometer') # that allow for unit name alises + Distance(0.1 nm) + >>> r > Distance(9.0, units='pm') # also allow for comparisons + True + >>> r.to('eV') # and raise exceptions for impossible conversion + ... + TypeError: No viable unit conversion from Unit(Å) -> eV + +New quantities with units should be autode values. + +Versioning +~~~~~~~~~~ + +Versioning is semantic in the **X.Y.Z** style. X is unlikely to exceed 1 +baring a complete rewrite; Y should be updated whenever there are +backwards incompatible changes; Z should be incremented for bugfixes and +tweaks that maintain all components of the public facing API. + + +Tests +----- + +As much as possible, contributions should be tested. + +Tests live in ``tests/``, with roughly one ``test_`` per module +or class. Unless your contribution adds a new module, your tests should +be added to an existing test file. diff --git a/autodE/source/doc/dev/index.rst b/autodE/source/doc/dev/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..a93c140c5cab05e20937fe5ac80c1ab489d20338 --- /dev/null +++ b/autodE/source/doc/dev/index.rst @@ -0,0 +1,16 @@ +*********** +Development +*********** + +Contributions in any form are very much welcome - the following documentation +is aimed at guiding new developers. Development is organised on `GitHub +Projects `_ and discussed +on the `Slack workspace `_. The following +sections are a work in progress! + + +.. toctree:: + :maxdepth: 1 + + overview + contributing diff --git a/autodE/source/doc/dev/overview.rst b/autodE/source/doc/dev/overview.rst new file mode 100644 index 0000000000000000000000000000000000000000..42cba50df3fd41c0cb43668d24333f98042cb1b1 --- /dev/null +++ b/autodE/source/doc/dev/overview.rst @@ -0,0 +1,103 @@ +************** +Code Structure +************** + + +Overview +######## + +The **autodE** code base is structured around a :code:`Reaction` class, due to +the initial singular goal of calculating reaction profiles. + +.. image:: ../common/reaction_simple_uml.svg + :target: ../_images/reaction_simple_uml.svg + :width: 550 + :align: center + +| +| + +The key attributes include :code:`reacs` and :code:`prods` as individual molecules +comprising the reactants and products of the reaction. Transition states connecting +reactants and products are held in :code:`tss`. From these, property attributes are generated, +including :code:`reactant` and :code:`product`, which are the corresponding +association complexes of all reactants and products. The :code:`ts` property is +simply the lowest energy transition state and :code:`None` if +:code:`len(reaction.tss) == 0`. + + +Overall class structure +####################### + +Zooming out, the composition and inheritance between of some of the +classes arising from :code:`Reaction` (center) is shown below. + +| + +.. image:: ../common/simple_uml.svg + :target: ../_images/simple_uml.svg + +| + +Species +******* + +Individual atoms are collected into a :code:`Atoms` class which becomes an +(effective) attribute of an :code:`AtomCollection`, which serves as a base +class for all objects with associated :code:`atoms`. A :code:`Species` adds +a molecular graph, solvent and name attributes and is the parent class of +a :code:`Molecule`, :code:`Complex` and :code:`TransitionState`. + +Values +****** + +Quantities with associated units e.g. an angle or energy are subclasses of +:code:`autode.values.Value`, which facilitate the conversion between units +(using :code:`value.to()`). For example, :code:`species.energy` returns a +:code:`PotentialEnergy` instance (if the energy has been calculated with e.g. +:code:`species.single_point()`). + + +Solvent +******* + +Species in the gas phase have :code:`species.solvent == None` while solvated +ones have an instance of either :code:`ImplicitSolvent` or :code:`ExplicitSolvent`, +inheriting from the base :code:`autode.solvent.Solvent` class. Explicit solvent +contains defined atoms, thus inherits also from :code:`AtomCollecion`. + + +Calculation +*********** + +Energies and derivatives thereof are obtained but running calculations using +external QM packages (e.g. Gaussian, etc.) through a :code:`Calculation` instance. +A :code:`Calculation` is initialised with a :code:`Species`, using a +:code:`Method` and :code:`Keywords` describing the type of calculation to +perform. Please reach out via `email `_ or slack to add a a new method. + + +calculate_reaction_profile +************************** + +From the description of a reaction (e.g SMILES strings), **autodE** can generate +the reaction profile. It starts by building a :class:`Reaction` instance, +describing the reactants/products involved (see :class:`Molecule`) as well as +the transition states (see :code:`TransitionStates`). Calling the +:code:`calculate_reaction_profile` method on the reaction instance first locates +the lowest energy conformers of each reactant and product +(:code:`species.find_lowest_energy_conformer()`). The intermediate optimisations +are performed using a :code:`Calculation` instance, which is responsible for calling +a specified QM package (e.g. XTB or Gaussian) with :code:`calculation.run()`. +The generated output is then parsed and the output available from the calculation +instance e.g. :code:`calculation.get_final_atoms()`. A :code:`Calculation` +is constructed with a :code:`Method`, which serves as the QM wrapper. From +optimised reactants and products a transition state (TS) search is performed +by constructing association complexes of reactants and products, then searching +over bond additions and deletions to traverse a reasonable path. Once the +:code:`TransitionStates` instance has been populated the lowest is selected to +perform a conformer search. If required, the conformational space of the +:code:`ReactantComplex` and :code:`ProductComplex` attributes of the reaction +are optimised. Hessian calculations are performed if the thermochemical contributions +to the energy are required, followed by single-point energy evaluations on the +final geometries. diff --git a/autodE/source/doc/examples/conformers.rst b/autodE/source/doc/examples/conformers.rst new file mode 100644 index 0000000000000000000000000000000000000000..52caa05a38e487cd5316773d965c09915d385e95 --- /dev/null +++ b/autodE/source/doc/examples/conformers.rst @@ -0,0 +1,71 @@ +******************** +Conformer Generation +******************** + +**autodE** generates conformers using two methods: (1) +`ETKDGv2 `_ implemented in +`RDKit `_ and (2) a randomize & relax (RR) algorithm. + + +Butane +------ + +To generate conformers of butane initialised from a SMILES string defaults to +using ETKDGv2. The molecule's conformers are a list of +:ref:`Conformer ` objects, a subclass of :ref:`Species `. + +.. code-block:: python + + >>> import autode as ade + >>> butane = ade.Molecule(name='butane', smiles='CCCC') + >>> butane.populate_conformers(n_confs=10) + >>> len(butane.conformers) + 2 + +where although 10 conformers requested only two are generated. This because +by default there is an RMSD threshold used to remove identical conformers. To +adjust this threshold + +.. code-block:: python + + >>> ade.Config.rmsd_threshold = 0.01 + >>> butane.populate_conformers(n_confs=10) + >>> len(butane.conformers) + 8 + +For organic molecules ETKDGv3 is highly recommended, while for metal +complexes the RR algorithm is used by default. To use RR for butane + +.. code-block:: python + + >>> butane.rdkit_conf_gen_is_fine = False + >>> butane.populate_conformers(n_confs=10) + >>> for conformer in butane.conformers: + ... conformer.print_xyz_file() + +Out (visualised) + +.. image:: ../common/conformers.png + +.. note:: + RMSD used by the RR algorithm applies to all atoms and does not account for + symmetry (e.g. methyl rotation) + + +Metal Complex +------------- + +.. image:: ../common/vaskas.png + +Arbitrary distance constraints can be added in a RR conformer generation. For +example, to generate conformers of +`Vaska's complex `_ +while retaining the square planar geometry + + +.. literalinclude:: ../common/vaskas_conformers.py + +Out (visualised) + +.. image:: ../common/vaskas_conformers.png + diff --git a/autodE/source/doc/examples/index.rst b/autodE/source/doc/examples/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..75cf4d07fd05c851668b446856ba94cc12eb67b8 --- /dev/null +++ b/autodE/source/doc/examples/index.rst @@ -0,0 +1,24 @@ +******** +Examples +******** + +**autodE** generates more than reaction profiles. The following examples +outline some typical use cases including *Python* based +molecular manipulation, generating relaxed and unrelaxed potential energy +surface (PES) scans and conformational searching. + + +.. toctree:: + :maxdepth: 1 + + species + molecules + reactions + nci + manipulation + pes1d + pes2d + conformers + rmsd + tss + thermochem diff --git a/autodE/source/doc/examples/manipulation.rst b/autodE/source/doc/examples/manipulation.rst new file mode 100644 index 0000000000000000000000000000000000000000..85d7d5743e5c6f1b4d805cadcc70a9c12f851ccd --- /dev/null +++ b/autodE/source/doc/examples/manipulation.rst @@ -0,0 +1,89 @@ +********************** +Molecular Manipulation +********************** + +**autodE** provides some simple methods for molecular manipulation and more +functionality when combined with `molfunc `_. + +Fragmentation +------------- + +From a molecular graph representation of a molecule its fragmentation is +relatively straightforward. For example, to fragment methane to CH\ :sub:`3`\ • + +H• + +.. code-block:: python + + >>> import autode as ade + >>> methane = ade.Molecule(smiles='C') + >>> [atom.atomic_symbol for atom in methane.atoms] + ['C', 'H', 'H', 'H', 'H'] + +.. code-block:: python + + >>> from autode.mol_graphs import split_mol_across_bond + >>> ch3_nodes, h_nodes = split_mol_across_bond(methane.graph, bond=(0, 1)) + >>> ch3 = ade.Molecule(name='CH3', mult=2, atoms=[methane.atoms[i] for i in ch3_nodes]) + >>> ch3.atoms + Atoms(n_atoms=4, [Atom(C, 0.0009, 0.0041, -0.0202), + Atom(H, -0.4585, 0.9752, -0.3061), + Atom(H, 0.0853, -0.0253, 1.0804), + Atom(H, 1.0300, -0.1058, -0.4327)]) + >>> h = ade.Molecule(name='H', mult=2, atoms=[methane.atoms[i] for i in h_nodes]) + >>> h.atoms + Atoms(n_atoms=1, [Atom(H, -0.6577, -0.8481, -0.3214)]) + + +Functionalisation +----------------- + +.. image:: ../common/functionalisation.png + +Swapping fragments on a structure (e.g. H → Me) can be achieved using SMILES +concatenation. For example to stitch two methyl fragments to generate an +ethane molecule + +.. code-block:: python + + >>> ethane = ade.Molecule(name='C2H6', smiles='C%99.C%99') + >>> ethane.n_atoms + 8 + +Multiple fragments can be added to the same core by specifying multiple sites +on a single atom + +.. code-block:: python + + >>> propane = ade.Molecule(name='C3H8', smiles='C%99%98.C%99.C%98') + >>> propane.n_atoms + 11 + +This method regenerates the whole structure which may not be desirable if the +molecule is a transition state (TS) or a particular conformation of interest. + +molfunc +_______ + +Adding a fragment to a fixed core structure can be achieved with +`molfunc `_ and can be installed +with: :code:`pip install molfunc`. **molfunc** requires a xyz file to +initialise a molecule and indexes atoms from 1 so that atom 2 is the first +hydrogen atom in methane + +.. code-block:: python + + >>> from molfunc import CoreMolecule, CombinedMolecule + >>> methane.print_xyz_file() + >>> methane_core = CoreMolecule(xyz_filename='CH4.xyz', atoms_to_del=[2]) + >>> ethane = CombinedMolecule(methane_core, frag_smiles='C[*]', name='C2H6') + >>> ethane.n_atoms + 8 + +A set of fragments can be iterated through using **molfunc** to generate a +library rapidly e.g. + +.. literalinclude:: ../common/methane_molfunc.py + +Out (visualised): + +.. image:: ../common/molfunc_functionalisation.png diff --git a/autodE/source/doc/examples/molecules.rst b/autodE/source/doc/examples/molecules.rst new file mode 100644 index 0000000000000000000000000000000000000000..973c4bf68f7c882731b9d5538ecad636dc087c98 --- /dev/null +++ b/autodE/source/doc/examples/molecules.rst @@ -0,0 +1,219 @@ +********* +Molecules +********* + +Reactants and Products are :ref:`Molecules ` and are initialised +much like their :ref:`Species ` parent, but have charge and +multiplicity defaults (0, 1 respectively) and can be built from +`SMILES `_ +strings + +.. code-block:: python + + >>> import autode as ade + >>> molecule = ade.Molecule(name='molecule') + >>> molecule.charge + 0 + >>> molecule.mult + 1 + +or from 3D structures given as xyz files directly + + >>> ch4 = ade.Molecule('methane.xyz') + >>> ch4.name + 'methane' + +--------------- + +Simple Example +-------------- + +.. image:: ../common/water.png + +To generate a water Molecule from its SMILES string ('O'), where hydrogen atoms +are implied + +.. code-block:: python + + >>> water = ade.Molecule(name='h2o', smiles='O') + >>> water.atoms + Atoms(n_atoms=3, [Atom(O, -0.001, 0.363, -0.000), + Atom(H, -0.825, -0.182, -0.000), + Atom(H, 0.826, -0.181, 0.000)]) + +Molecules also add a molecular graph attribute as a `NetworkX `_ +:code:`Graph` and contain node (atoms) and edge (bonds) attributes + +.. code-block:: python + + >>> water.graph + MolecularGraph(|E| = 2, |V| = 3) + >>> water.graph.nodes + NodeView((0, 1, 2)) + >>> water.graph.edges + EdgeView([(0, 1), (0, 2)]) + +where in water there are three atoms {0, 1, 2} and two bonds. The 3D structure +can be generated as a .xyz file for viewing in molecular visualisation software +(Avogadro, Chimera, VMD, Mercury, Maestro etc.) with + +.. code-block:: python + + >>> water.print_xyz_file() + +where 'h2o.xyz' is generated in the current working directory. + +--------------- + +Geometry Manipulation +--------------------- + +.. figure:: ../common/water_shift.png + +Whole molecules can be translated and rotated. For example, to translate the +water molecule so the oxygen atom is centred at the origin + +.. code-block:: python + + >>> water.coordinates + Coordinates([[-0.0011, 0.3631, -0. ], + [-0.825 , -0.1819, -0. ], + [ 0.8261, -0.1812, 0. ]]) + >>> o_atom = water.atoms[0] + >>> water.translate(vec=-o_atom.coord) + >>> water.coordinates + Coordinates([[ 0. , 0. , 0. ], + [-0.8250, -0.1819, 0. ], + [ 0.8261, -0.1812, 0. ]]) + +then rotate around the x axis + +.. code-block:: python + + >>> import numpy as np + >>> water.rotate(axis=[1.0, 0.0, 0.0], theta=np.pi) + >>> water.coordinates + Coordinates([[ 0. , 0. , 0. ], + [-0.8250, 0.1819, 0. ], + [ 0.8261, 0.1812, 0. ]]) + +Angles between atoms in a molecule can be also calculated + +.. code-block:: python + + >>> water.angle(1, 0, 2) + Angle(1.9752 rad) + +where atoms are indexed from 0, so the angle is θ(H-O-H). As with distances, +explicit unit conversion is supported + +.. code-block:: python + + >>> water.angle(1, 0, 2).to('deg') + Angle(113.17085 °) + + + +Calculations +------------ + +.. image:: ../common/water_opt_energy.png + +**autodE** provides wrappers around common electronic structure theory packages +(ORCA, XTB, NWChem, MOPAC, Gaussian09, Gaussian16, QChem) so geometries may be +optimised and energies calculated. + +For example, to optimise the geometry of a water molecule at the XTB level and +then perform a single point energy evaluation with ORCA + +.. code-block:: python + + >>> water.optimise(method=ade.methods.XTB()) + >>> water.energy + Energy(-5.07054 Ha) + >>> water.single_point(method=ade.methods.ORCA()) + >>> water.energy + Energy(-76.37766 Ha) + +where the default single point method in ORCA is PBE0-D3BJ/def2-TZVP. Like with +other values (distances, angles, dihedrals) converting to different units is as +simple as + +.. code-block:: python + + >>> water.energy.to('kcal') + Energy(-47927.6682 kcal mol-1) + +:code:`water.energy` returns the most recently evaluated energy at this geometry, +but the XTB energy is still saved in :code:`water.energies`. Printing the energies +along with their associated methods + +.. code-block:: python + + >>> for energy in water.energies: + ... energy, energy.method_str + ... + Energy(-5.07054 Ha) xtb + Energy(-76.37766 Ha) orca PBE0-D3BJ/def2-TZVP + + +Modifying the method is possible by setting keywords. To set the single point +keywords for an instance of the ORCA wrapper + +.. code-block:: python + + >>> orca = ade.methods.ORCA() + >>> orca.keywords.sp = ['PBE0', 'D3BJ', 'ma-def2-TZVP'] + >>> water.single_point(method=orca) + >>> water.energy + Energy(-76.37938 Ha) + +Keywords can also be passed as arguments to :code:`single_point`, :code:`optimise` +and :code:`calc_thermo`. For example: + +.. code-block:: python + + >>> water.single_point(method=ade.methods.ORCA(), + ... keywords=['PBE0', 'D3BJ', 'ma-def2-TZVP']) + +will do an identical calculation to the above example. + +Alternatively, to set the keywords for every instance of :code:`ORCA` created, +use :code:`ade.Config` e.g. + + +.. code-block:: python + + >>> ade.Config.ORCA.keywords.sp = ['PBE0', 'D3BJ', 'ma-def2-TZVP'] + >>> instance_1 = ade.methods.ORCA() + >>> instance_1.keywords.sp + SPKeywords(PBE0 D3BJ ma-def2-TZVP) + >>> instance_2 = ade.methods.ORCA() + >>> instance_2.keywords.sp + SPKeywords(PBE0 D3BJ ma-def2-TZVP) + + +.. note:: + + Structure optimisation resets the positions of the atoms to their optimised + value. + +Calculations can also be performed using electronic structure packages with +implemented wrappers. For example, to calculate a single point energy for a +hydrogen atom with all the currently implemented methods + +.. code-block:: python + + >>> from autode.methods import MOPAC, XTB, QChem, NWChem, G09, G16, ORCA + >>> + >>> h = ade.Molecule(name='H', mult=2, atoms=[ade.Atom('H')]) + >>> + >>> h.single_point(method=MOPAC()) + >>> h.single_point(method=XTB()) + >>> h.single_point(method=QChem()) + >>> h.single_point(method=NWChem()) + >>> h.single_point(method=G09()) + >>> h.single_point(method=G16()) + >>> h.single_point(method=ORCA()) + + diff --git a/autodE/source/doc/examples/nci.rst b/autodE/source/doc/examples/nci.rst new file mode 100644 index 0000000000000000000000000000000000000000..288375452aeeceb4226b039440cd177c2b463f42 --- /dev/null +++ b/autodE/source/doc/examples/nci.rst @@ -0,0 +1,45 @@ +********************************** +Non-covalent Interaction Complexes +********************************** + +**autodE** allows for the systematic search of a NCI complexes conformational +space. For example, to find the lowest energy structure of the water trimer: + +.. literalinclude:: ../common/water_trimer.py + + +Out (visualised) + +.. image:: ../common/water_trimer.png + :width: 650 + + +| + +The parameters (:code:`num_complex_sphere_points` and :code:`num_complex_random_rotations`) +define the number of generated conformers, up to :code:`ade.Config.max_num_complex_conformers`. + + +.. image:: ../common/water_trimer_expl.png + :width: 550 + :align: center + +| +| + +By default, **autodE** will exclude any conformers with differing connectivity, thus +may not generate any conformers of e.g. a [M(H\ :sub:`2`\O)\ :sub:`m`\]\ :sub:`n+`\ system. +Complexes of systems with dative bonds can be generated by including +:code:`allow_connectivity_changes=True`. For example, with a +[Na(H\ :sub:`2`\O)\ :sub:`3`\]\ :sup:`+`\ system: + +.. literalinclude:: ../common/na_h2o_3.py + + +Out (visualised) + +.. image:: ../common/na_h2o_3_confomers.png + :width: 550 + :align: center + + diff --git a/autodE/source/doc/examples/pes1d.rst b/autodE/source/doc/examples/pes1d.rst new file mode 100644 index 0000000000000000000000000000000000000000..a2915e5899583d505774bacd2a2378f32c3f233e --- /dev/null +++ b/autodE/source/doc/examples/pes1d.rst @@ -0,0 +1,65 @@ +***************** +1D PES Generation +***************** + + +**autodE** allows for both potential energy surface (PES) to be constructed +where other degrees of freedom are frozen (unrelaxed) or allowed to +optimise (relaxed). + + +Unrelaxed +--------- + +For the O-H dissociation curve in H\ :sub:`2`\ O at the XTB level: + +.. literalinclude:: ../common/OH_PES_unrelaxed.py + +Out (OH_PES_unrelaxed.png): + +.. image:: ../common/OH_PES_unrelaxed.png + :width: 550 + :align: center + +For the same O-H 1D PES scan using a selection of different DFT methods: + +.. literalinclude:: ../common/OH_PES_unrelaxed_DFT.py + +Out (OH_PES_unrelaxed2.png): + +.. image:: ../common/OH_PES_unrelaxed_DFT.png + :width: 550 + :align: center + +Relaxed +------- + +A relaxed 1D PES can be generated and plotted using the default :code:`plot` +method for the same O-H stretch using: + +.. literalinclude:: ../common/OH_PES_relaxed.py + +Out (OH_PES_relaxed.png): + +.. image:: ../common/OH_PES_relaxed.png + :width: 500 + :align: center + +.. code-block:: + + r_1 (Å) E (Ha) + 0.6500 -4.93638 + 0.7464 -5.01741 + 0.8429 -5.05749 + 0.9393 -5.07023 + 1.0357 -5.06677 + 1.1321 -5.05464 + 1.2286 -5.03825 + 1.3250 -5.02002 + 1.4214 -5.00116 + 1.5179 -4.98253 + 1.6143 -4.96472 + 1.7107 -4.94807 + 1.8071 -4.93276 + 1.9036 -4.91887 + 2.0000 -4.90642 diff --git a/autodE/source/doc/examples/pes2d.rst b/autodE/source/doc/examples/pes2d.rst new file mode 100644 index 0000000000000000000000000000000000000000..4c954a7131b44a52d950db0bf063d961dd3f4c0a --- /dev/null +++ b/autodE/source/doc/examples/pes2d.rst @@ -0,0 +1,36 @@ +***************** +2D PES Generation +***************** + + +2D scans in **autodE** are available over distances and are optimally parallelised +over the available number of cores. For example, to calculate and plot the 2D +relaxed surface over the forming C-C distances in a Diels-Alder reaction between +ethene and butadiene: + + +.. literalinclude:: ../common/DA_2d.py + + +Out: + +.. image:: ../common/DA_surface.png + + +Surfaces can also be interpolated using a +`spline `_. +To reload the PES and plot using a 4-fold interpolation (i.e. 10 -> 40 points in each dimension): + + +.. literalinclude:: ../common/DA_2d_interp.py + + +Out: + +.. image:: ../common/DA_surface_interpolated.png + + +where the .xyz file (cyclohexene.xyz) used is: + +.. literalinclude:: ../common/cyclohexene.xyz + diff --git a/autodE/source/doc/examples/reactions.rst b/autodE/source/doc/examples/reactions.rst new file mode 100644 index 0000000000000000000000000000000000000000..e3000b38f4ad97b15ed9968fac3210dc0eeb4c3e --- /dev/null +++ b/autodE/source/doc/examples/reactions.rst @@ -0,0 +1,100 @@ +********* +Reactions +********* + +Reactions in **autode** are :ref:`Reaction ` objects constructed from +either SMILES strings or :code:`Reactant` and :code:`Product` s. These are +elementary reactions, so the reactants should be linked to the products without +any intermediates. To initialise a reaction for: ethene + butadiene → cyclohexene: + + +.. code-block:: python + + >>> import autode as ade + >>> + >>> ethene = ade.Reactant(smiles='C=C') + >>> butadiene = ade.Reactant(smiles='C=CC=C') + >>> cyclohexene = ade.Product(smiles='C1=CCCCC1') + >>> + >>> rxn = ade.Reaction(ethene, butadiene, cyclohexene) + +.. figure:: ../common/diels_alder.png + +Reactions default to the gas phase and room temperature + +.. code-block:: python + + >>> rxn.solvent is None + True + >>> rxn.temp # in K + 298.15 + +Energy differences can be calculated for the overall reaction or to the +transition state (TS). If the energy of reactants and products has not been +calculated, then the energy differences are :code:`None` + +.. code-block:: python + + >>> rxn.delta('E') is None # ∆E_r + True + >>> rxn.delta('E‡') is None # ∆E‡ + True + +Calculating energies for the reactants and products allows for the reaction +energy difference to be calculated + +.. code-block:: python + + >>> for mol in (ethene, butadiene, cyclohexene): + ... mol.optimise(method=ade.methods.XTB()) + >>> + >>> rxn.delta('E').to('kcal mol-1') + Energy(-67.44178 kcal mol-1) + +If a TS has not been located for the reaction then it is assumed to be +barrierless and the barrier estimated from a diffusion limited process + +.. code-block:: python + + >>> rxn.is_barrierless and rxn.delta('E‡').is_estimated + True + >>> rxn.delta('E‡').to('kcal mol-1') + Energy(4.35491 kcal mol-1) + +To optimise the reactants and products then locate the transition state using +8 cores + +.. code-block:: python + + >>> ade.Config.n_cores = 8 + >>> rxn.optimise_reacs_prods() + >>> rxn.locate_transition_state() + >>> + >>> rxn.ts + TransitionState(TS_g1R2_X_ll_ad_2-3_4-5, n_atoms=16, charge=0, mult=1) + >>> # ∆E‡ is now no longer an estimate + >>> rxn.delta('E‡').to('kcal mol-1') + Energy(14.30068 kcal mol-1) + + +Identity reactions where reactants and products are identical are not, by default, +supported in **autode** as the bond rearrangement of interest is not easily inferred. +However, reaction profiles for identity reactions may be calculated by defining +*atom classes* to distinguish otherwise identical atoms. For example + +.. code-block:: python + + >>> rxn = ade.Reaction('[Br-:1].C[Br:2]>>C[Br:1].[Br-:2]', solvent_name='water') + >>> # bond rearrangement leading to products is well defined + >>> rxn.calculate_reaction_profile() + +calculates the profile for the Br- + CH3Br -> BrCH3 + Br- SN2 reaction. An +:code:`atom.atom_class` attribute is set when defined in the SMILES string. This +may be set directly, with the following two molecules being identical + +.. code-block:: python + + >>> mol_a = ade.Molecule(smiles='[He:1]') + >>> mol_b = ade.Molecule(atoms=[ade.Atom('He', atom_class=1)]) + >>> mol_a.atoms[0].atom_class == mol_b.atoms[0].atom_class == 1 + True diff --git a/autodE/source/doc/examples/rmsd.rst b/autodE/source/doc/examples/rmsd.rst new file mode 100644 index 0000000000000000000000000000000000000000..b231538161f4a7d371e433ca6e4872bc5b52d478 --- /dev/null +++ b/autodE/source/doc/examples/rmsd.rst @@ -0,0 +1,20 @@ +**** +RMSD +**** + +**autodE** can be used to calculate RMSD values between different molecules. For +example, a script that compares .xyz files in the current directory and +copies them to a folder (*unique_conformers*) if they are unique based on an +RMSD threshold + +.. literalinclude:: ../common/rmsd.py + +which can be used:: + + $ python rmsd.py conformer1.xyz. conformer2.xyz -t 0.1 + + +.. note:: + There are many other Python packages to calculate RMSD e.g. + `rmsd `_ and + `spyrmsd `_ which may be better! diff --git a/autodE/source/doc/examples/species.rst b/autodE/source/doc/examples/species.rst new file mode 100644 index 0000000000000000000000000000000000000000..d44803dbad75ce60cd1df6108d1cd302ff205580 --- /dev/null +++ b/autodE/source/doc/examples/species.rst @@ -0,0 +1,168 @@ +******* +Species +******* + +**autodE** provides Molecule classes built from a base +:ref:`Species ` class. A Species needs to be initialised from +a name, set of atoms (or possibly None), charge and +`spin multiplicity `_ + +.. code-block:: python + + >>> import autode as ade + >>> species = ade.Species(name='species', atoms=None, charge=0, mult=1) + >>> species.n_atoms + 0 + +Atoms are a list of :ref:`Atom ` objects and can be used to initialise +a species i.e. + +.. code-block:: python + + >>> h2 = ade.Species(name='H2', charge=0, mult=1, atoms=[ade.Atom('H'), ade.Atom('H')]) + >>> h2 + Species(H2, n_atoms=2, charge=0, mult=1) + +Atoms contain a coordinate as a numpy array (shape = (3,), initialised at the +origin) and a few properties + +.. code-block:: python + + >>> atom1, atom2 = h2.atoms + >>> atom1 + Atom(H, 0.0000, 0.0000, 0.0000) + >>> atom1.coord + Coordinate([0. 0. 0.] Å) + >>> atom1.atomic_number + 1 + >>> atom1.atomic_symbol + 'H' + >>> atom1.group + 1 + >>> atom1.period + 1 + + +Rotation and Translation +------------------------ + +Atoms can be translated and rotated e.g. to shift the first hydrogen atom +from the origin along 1 Å in the x axis then rotate in the z-axis + +.. image:: ../common/translation_rotation.png + +.. code-block:: python + + >>> vector = [1.0, 0.0, 0.0] + >>> atom1.translate(vector) + >>> atom1.coord + Coordinate([1. 0. 0.] Å) + +To rotate this atom 180° (π radians) in the z-axis at the origin + +.. code-block:: python + + >>> atom1.rotate(theta=3.14159, axis=[0.0, 0.0, 1.0]) + >>> atom1.coord + Coordinate([-1. 0. 0.] Å) + +.. note:: + Rotations are performed anticlockwise + +Translations and rotations are performed in place so the h2 atoms are modified + +.. code-block:: python + + >>> h2.atoms + Atoms(n_atoms=2, [Atom(H, -1.00, 0.00, 0.00), Atom(H, 0.00, 0.00, 0.00)]) + + +Distances +--------- + +Distances between atom pairs can be calculated, where atoms are indexed from 0. To +calculate the bond length for this species + +.. code-block:: python + + >>> h2.distance(0, 1) + Distance(1.0 Å) + +Distances support conversion into other units (bohr, nano/pico-meters), as well as +all standard mathematical operations + +.. code-block:: python + + >>> h2.distance(0, 1).to('a0') + Distance(1.88973 bohr) + + >>> 2 * h2.distance(0, 1) + Distance(2.0 Å) + + + +Angles +------ + +Bond angles can be calculated between three atoms. For example, in a water molecule + +.. code-block:: python + + >>> h2o = ade.Species(name='H2O', charge=0, mult=1, + ... atoms=[ade.Atom('H', x=-1.0), + ... ade.Atom('O'), + ... ade.Atom('H', x=0.25, y=0.97)]) + >>> h2o.angle(0, 1, 2).to('degrees') + Angle(104.45247 °) + + +Similarly, dihedral angles are available using :code:`Species.dihedral`. + +Solvents +-------- + +Species also support a solvent, which need not be specified for a species in +the gas phase + +.. code-block:: python + + >>> h2.solvent is None + True + +For example, to initialise a fluoride ion in dichloromethane + +.. code-block:: python + + >>> f = ade.Species(name='F-', charge=-1, mult=1, + ... atoms=[ade.Atom('F')], + ... solvent_name='DCM') + >>> f.solvent + Solvent(dichloromethane) + +Given a solvent name string a :ref:`Solvent ` is added as an attribute +to the species. A Solvent contains a set of aliases and names of the implicit +solvent in different electronic structure theory packages e.g. + + >>> f.solvent.g09 + 'Dichloromethane' + >>> f.solvent.xtb + 'CH2Cl2' + + +Species from Files +------------------ + +Species may be initialised from `xyz files `_ +using the io module + +.. code-block:: python + + >>> from autode.input_output import xyz_file_to_atoms + >>> methane = ade.Species(name='CH4', charge=0, mult=1, + ... atoms=xyz_file_to_atoms('methane.xyz')) + >>> methane + Species(CH4, n_atoms=5, charge=0, mult=1) + +.. note:: + Only .xyz files are supported currently. Other molecular file formats can + be converted to .xyz with `openbabel `_. diff --git a/autodE/source/doc/examples/thermochem.rst b/autodE/source/doc/examples/thermochem.rst new file mode 100644 index 0000000000000000000000000000000000000000..1eb2d60d2ef8d8870900a279d997b2b93ea5ca3f --- /dev/null +++ b/autodE/source/doc/examples/thermochem.rst @@ -0,0 +1,170 @@ +*************** +Thermochemistry +*************** + +Thermochemical contributions are calculated in **autodE** using the ideal gas +model and variants thereof. The molar enthalpy is, + +.. math:: + H = E_\text{elec} + E_\text{internal} + E_\text{ZPE} + RT + +and Gibbs free energy, + +.. math:: + G = H - TS + +where :math:`T` is the temperature and :math:`R` the gas constant. See +`here `_ +for a more in-depth mathematical background. With a completed electronic +structure calculation free energies can be obtained using either RRHO, +shifted-RRHO (from Truhlar[1]) and qRRHO (from Grimme[2]). Global parameters +can also be set in :code:`autode.Config`, which apply to all thermochemical +calculations including those when calculating a profile with +:code:`rxn.calculate_reaction_profile(free_energy=True)` + +****** + +General +------- + +To calculate thermochemical contributions (enthalpy, Gibbs free energy) in **autodE** the minimal required input +is + +.. code-block:: Python + + import autode as ade + + water = ade.Molecule(smiles='O') + water.calc_thermo() + print(f'E = {water.energy:.6f}', + f'H = {water.enthalpy:.6f}', + f'G = {water.free_energy:.6f}', + f'units = {water.energy.units}') + +**Out**: +:code:`E = -76.269260 +H = -76.245955 +G = -76.264398 +units = Unit(Ha)` + + +For a non-default set method and keywords: + +.. code-block:: Python + + import autode as ade + + water = ade.Molecule(smiles='O') + water.calc_thermo(method=ade.methods.G09(), + keywords=['PBEPBE', 'def2SVP', 'Freq']) + + +ORCA +---- +To calculate thermochemical contributions from a completed ORCA Hessian file (*H2O_hess_orca.hess*): + +.. code-block:: Python + + import autode as ade + + mol = ade.Molecule('H2O_hess_orca.xyz') + orca = ade.methods.ORCA() + + calc = ade.Calculation(name='H2O', + molecule=mol, + method=orca, + keywords=orca.keywords.hess) + calc.output.filename = 'H2O_hess_orca.hess' + + mol.calc_thermo(calc=calc, temp=298.15, ss='1atm', sn=1) + print(mol.g_cont) + +**Out**: :code:`0.00145779587867773` + +which differs from the ORCA-calculated value (0.00145673 Ha) by <0.001 kcal mol\ :sup:`-1`\. To +calculate a total free energy including :math:`E_\text{elec}` both a .out and .hess file need to be present with the +same basename. For example: + +.. code-block:: Python + + import autode as ade + + mol = ade.Molecule('H2O_hess_orca.xyz') + orca = ade.methods.ORCA() + + calc = ade.Calculation(name='H2O', + molecule=mol, + method=orca, + keywords=orca.keywords.hess) + calc.output.filename = 'H2O_hess_orca.out' + + mol.calc_thermo(calc=calc) + print(f'H = {mol.enthalpy:.6f} Ha\n' + f'G = {mol.free_energy:.6f} Ha') + +**Out**: + +.. code-block:: + + H = -76.249086 Ha + G = -76.267526 Ha + +where, without any arguments to :code:`calc_thermo`, the default method uses room temperature (298.15 K), +a one molar (1 M) standard state (appropriate for molecules in solution), Grimme's qRRHO treatment of +low frequency vibrational modes and a calculated symmetry number, which in this case is two (C\ :sub:`2v` \ symmetry). + +****** + +Gaussian +-------- + +Likewise from a Gaussian output file of butane (*butane_hess_g09.log*): + +.. code-block:: Python + + import autode as ade + + mol = ade.Molecule('butane.xyz') + g09 = ade.methods.G09() + + calc = ade.Calculation(name='butane', + molecule=mol, + method=g09, + keywords=g09.keywords.hess) + calc.output.filename = 'butane_hess_g09.log' + + mol.calc_thermo(calc=calc, temp=298.15, ss='1atm', sn=1, lfm_method='igm') + print(mol.g_cont) + +**Out**: :code:`0.10419152589407932` + +which differs from the Gaussian-calculated value (0.104216 Ha) by ~0.01 kcal mol\ :sup:`-1`\. + +.. note:: + + Gaussian 09 has very tight tolerances on symmetry and uses a pure + harmonic oscillator treatment of low frequency modes. + +***************** + +Frequency scaling +----------------- + +Default methods use frequency scaling automatically to generate the most accurate +thermochemistry possible. Unscaled frequencies can be obtained by setting + +.. code-block:: Python + + ade.Config.freq_scale_factor = 1.0 + +.. note:: + + The above examples were generated using unscaled frequencies. + + +References +---------- + +[1] R. F. Ribeiro, A. V. Marenich, C. J. Cramer and D. G. Truhlar, *Phys. Chem. B* 2011, **115**, 14556. + +[2] S. Grimme, *Chem. Eur. J.* 2012, **18**, 9955. diff --git a/autodE/source/doc/examples/tss.rst b/autodE/source/doc/examples/tss.rst new file mode 100644 index 0000000000000000000000000000000000000000..2f5994573f8ac0cdcdc3e6b0c1c679185c65eb1f --- /dev/null +++ b/autodE/source/doc/examples/tss.rst @@ -0,0 +1,84 @@ +***************** +Transition States +***************** + +In addition to generating full reaction profiles directly **autodE** provides +automated access to transition states (TSs). TSs are found either from a +reaction, where bond rearrangements are found and TS located along each +possible path, or from 3D structures of reactants & products, and a given bond +rearrangement. + + +.. warning:: + Transition states have no check that the stereochemistry is correctly preserved. + +------------ + +Default: Reaction +***************** + + +.. image:: ../common/curtius.png + +For a simple Curtius rearrangement copied as a SMILES string directly from +Chemdraw\ :sup:`TM`\ (selecting reactants and products with arrows and '+' then Edit->Copy As->SMILES) +the TS can be located with + + +.. literalinclude:: ../common/curtius.py + + +Out (visualised) + +.. image:: ../common/curtius_ts.png + :width: 370 + :align: center + + +.. note:: + :code:`locate_transition_state` only locates a single transtion state for + each possible bond rearrangment and does not attempt to search the conformational + space. + + +------------ + +CI-NEB +****** + +Minimum energy pathways can also be generated using nudged elastic band (NEB) +calculations. To find the peak species suitable as a TS guess geometry for +the prototypical Claisen rearrangement ([3,3]-sigmatropic rearrangement of +allyl phenyl ether) + + +.. literalinclude:: ../common/claisen_cineb.py + + +Out: + +.. image:: ../common/claisen_neb_optimised.png + :width: 580 + :align: center + + +Out (visualised): + +.. image:: ../common/claisen_peak.png + :width: 300 + :align: center + + +where the xyz files used are: + +.. literalinclude:: ../common/claisen_r.xyz + + +.. literalinclude:: ../common/claisen_p.xyz + + + +.. note:: + NEBs initialised from end points use linear interpolation then an image + independent pair potential to relax the initial linear path, following + `this paper `_. diff --git a/autodE/source/doc/index.rst b/autodE/source/doc/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..e4a49a22a61dc7764b4916a06d5748056b977e62 --- /dev/null +++ b/autodE/source/doc/index.rst @@ -0,0 +1,45 @@ +.. _contents: + +.. image:: common/logo.png + :width: 450 + :align: center + +Overview +======== + +.. _autodE: https://github.com/duartegroup/autodE + + +`autodE `_ is a Python module designed for the automated generation of reaction +profiles as fast as possible. Profiles are generated using a double-ended search form reactant(s) and product(s) +given as 1D SMILES or 3D structures. Functionality for conformational sampling (RR algorithm) and transition state +finding (NEB, CI-NEB, adapt.) is also available. + + +Documentation +------------- + +.. only:: html + + :Release: |version| + :Date: |today| + +.. toctree:: + :maxdepth: 1 + + install + quickstart + config + reference/index + troubleshooting + examples/index + changelog + dev/index + citation + Paper + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` diff --git a/autodE/source/doc/install.rst b/autodE/source/doc/install.rst new file mode 100644 index 0000000000000000000000000000000000000000..7d554915d336d27c8792a05bfd26b512c90b5fc6 --- /dev/null +++ b/autodE/source/doc/install.rst @@ -0,0 +1,143 @@ +Install +======= + +Dependencies +------------ +**autodE** is a Python package that relies on external electronic structure codes and requires: + +- `Python `_ > v. 3.7 + +- One of: + + + `ORCA `_ > v. 4.0 + + `Gaussian09 `_ + + `Gaussian16 `_ + + `NWChem `_ > v. 6.6 + + `QChem `_ > 5.4 + +- One of: + + + `XTB `_ > v. 6.1 + + `MOPAC `_ v. 2016 + + +Python dependencies listed `here `_ are best satisfied using conda +(`anaconda `_ or `miniconda `_); +the following guide assumes a conda install. + +****** + +Conda: Mac OSX / Linux +---------------------- + +**autodE** is available through `conda `_ and can be installed with:: + + $ conda install autode --channel conda-forge + + +If the environment cannot be solved see `here `_. +A Linux installation tutorial is available `here `_. + +****** + +Git: Mac OSX / Linux +-------------------- + +To build from source first clone the repository and ``cd`` there:: + + $ git clone https://github.com/duartegroup/autodE.git + $ cd autodE + + +then, install the appropriate dependencies (you may want to create a new `virtual +environment `_) and install:: + + $ conda install --file requirements.txt --channel conda-forge + $ pip install . + + +.. note:: + A working C++ compiler supporting C++11 is required. Tested with clang and gcc. + +Git: Windows +------------ + +Installing autodE on Windows from source is similar to that on Linux/Mac OS, but slightly +more involved. A C++ compiler needs to be installed, as it is not provided by default. It is +recommended to install Visual C/C++ compiler from `here `_. +Note that installing the "Build Tools for Visual Studio" and selecting only "Desktop Development with C++" +in the installer menu is sufficient. + +Git is also required, this can be either installed in the form of `Git for Windows `_ +or in a `Conda environment `_. With git, first clone the autodE +repository as shown above. + +Then open a Conda prompt or shell and cd to the directory where autodE has been cloned +and then install with pip as usual ((you may want to create a new `virtual +environment `_ +as mentioned before):: + + > conda install --file requirements.txt --channel conda-forge + > pip install . + +.. note:: + In rare cases :code:`pip` may not be able to find the Visual C/C++ compiler, despite the build + tools being installed and show the error message :code:`error: Microsoft Visual C++ 14.0 or greater is required` + . In this case, run the Visual Studio build tools command prompt, which is usually named + "x64 Native Tools Command Prompt for VS 2022" or something similar in the start menu (This will add compiler to + the PATH). Then run :code:`pip` from this command prompt. +.. note:: + Windows installation is also supported within Windows Subsystem for Linux (`WSL `_). + Simply follow the instructions for Linux. + + +****** + +Installation Check +------------------ + +**autodE** will find any electronic structure theory packages with implemented +wrappers (ORCA, NWChem, Gaussian, XTB and MOPAC) that are available from your +`PATH `_ environment variable. +To check the expected high and low level methods are available: + +.. code-block:: python + + >>> import autode as ade + >>> ade.methods.get_hmethod() + ORCA(available = True) + >>> ade.methods.get_lmethod() + XTB(available = True) + + +If a :code:`MethodUnavailable` exception is raised see the :doc:`troubleshooting page `. +If **autodE** cannot be imported please open a issue on `GitHub `_. + +****** + +Quick EST Test +-------------- + +If the high and/or low level electronic structure methods have been installed +for the first time, it may be useful to check they're installed correctly. +To run a quick optimisation of H\ :sub:`2`\: + +.. code-block:: python + + >>> import autode as ade + >>> h2 = ade.Molecule(smiles='[H][H]') + >>> h2.optimise(method=ade.methods.get_lmethod()) + >>> h2.optimise(method=ade.methods.get_hmethod()) + >>> h2.energy + Energy(-1.16401 Ha) + >>> h2.atoms + Atoms([Atom(H, 0.3805, 0.0000, 0.0000), Atom(H, -0.3805, 0.0000, 0.0000)]) + + +If an :code:`AtomsNotFound` exception is raised it is likely that the electronic structure +package is not correctly installed correctly. + +.. note:: + Calculations are performed on 4 CPU cores by default, thus the high and + low-level methods must be installed as their parallel versions where + appropriate. diff --git a/autodE/source/doc/make.bat b/autodE/source/doc/make.bat new file mode 100644 index 0000000000000000000000000000000000000000..2119f51099bf37e4fdb6071dce9f451ea44c62dd --- /dev/null +++ b/autodE/source/doc/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/autodE/source/doc/quickstart.rst b/autodE/source/doc/quickstart.rst new file mode 100644 index 0000000000000000000000000000000000000000..d2215d2579af2123c62832af72abfb5725dedc39 --- /dev/null +++ b/autodE/source/doc/quickstart.rst @@ -0,0 +1,100 @@ +Quick Start +=========== + +Python scripts for these and other examples can be found +`here `_. + +------------ + +Diels Alder +------------ + +.. image:: common/diels_alder.png + +For the simple [4+2] Diels-Alder reaction between ethene and butadiene the +reaction profile can be calculated in a couple of lines, where **autodE** +identifies reactants and products from the reaction `SMILES `_ +and executes using 8 CPU cores in 10 minutes or so using XTB and ORCA as the +low and high level methods respectively. + +.. code-block:: python + + >>> import autode as ade + >>> ade.Config.n_cores = 8 + + >>> rxn = ade.Reaction('C=CC=C.C=C>>C1=CCCCC1', name='DA') + >>> rxn.calculate_reaction_profile() + +A directory (*DA/*) will be created where electronic structure calculations have +been performed and an image of the reaction profile saved in the current working directory. See below: + +.. raw:: html + + + + +------------ + +S\ :sub:`N`\2 +------------- +.. figure:: common/sn2_image.png + :width: 550 + :align: center + +To generate a reaction profile for the S\ :sub:`N`\2 reaction between fluoride +and methyl chloride in water in more depth, we have the SMILES strings for the +reactant and products generated from Chemdraw (by selecting a +molecule → Edit → Copy As → SMILES): + +.. note:: + **Fluoride**: [F-]; **MeCl**: CCl; **Chloride**: [Cl-]; **MeF**: CF + +Import **autodE** and set the number of processing cores that are available for +this calculation: + +.. code-block:: python + + >>> import autode as ade + >>> ade.Config.n_cores = 4 + + +Initialise reactants and products from their respective SMILES strings giving +a name to each: + +.. code-block:: python + + >>> Fluoride = ade.Reactant(name='F-', smiles='[F-]') + >>> MeCl = ade.Reactant(name='CH3Cl', smiles='ClC') + >>> Chloride = ade.Product(name='Cl-', smiles='[Cl-]') + >>> MeF = ade.Product(name='CH3F', smiles='CF') + +From reactants and products form a reaction in water and calculate the reaction profile: + +.. code-block:: python + + >>> rxn = ade.Reaction(Fluoride, MeCl, Chloride, MeF, name='sn2', solvent_name='water') + >>> rxn.calculate_reaction_profile() + +This function call will generate a plot something like: + +.. image:: common/sn2_reaction_profile.png + :width: 550 + :align: center + + +as *sn2_reaction_profile.png* in the current working directory, where conformers +of the reactant and products have been searched and the profile calculated at +PBE0-D3BJ/def2-TZVP//PBE0-D3BJ/def2-SVP using an implicit water solvent. It +should take around 10 minutes to complete. + +.. note:: + **autodE** has default DFT methods set for optimisation and single point + calculations. Therefore, by default, structures are optimised at + PBE0-D3BJ/def2-SVP and single points calculations performed at + PBE0-D3BJ/def2-TZVP. To use other methods see the + :doc:`config page `. diff --git a/autodE/source/doc/reference/atoms.rst b/autodE/source/doc/reference/atoms.rst new file mode 100644 index 0000000000000000000000000000000000000000..62b8bbb1a3c1f3bc3b2e82e2ab5f3af94d90e97a --- /dev/null +++ b/autodE/source/doc/reference/atoms.rst @@ -0,0 +1,11 @@ +.. _atoms: + +***** +Atoms +***** + +.. automodule:: autode.atoms + :members: + :undoc-members: + :special-members: __init__ + :exclude-members: coordinate, centre_of_mass, moment_of_inertia diff --git a/autodE/source/doc/reference/bond_rearrangement.rst b/autodE/source/doc/reference/bond_rearrangement.rst new file mode 100644 index 0000000000000000000000000000000000000000..1ddf3836f43259ade79017be51f3b42d12b8e72d --- /dev/null +++ b/autodE/source/doc/reference/bond_rearrangement.rst @@ -0,0 +1,8 @@ +****************** +Bond Rearrangement +****************** + +.. automodule:: autode.bond_rearrangement + :members: + :undoc-members: + :special-members: __init__, __eq__, __str__ \ No newline at end of file diff --git a/autodE/source/doc/reference/bonds.rst b/autodE/source/doc/reference/bonds.rst new file mode 100644 index 0000000000000000000000000000000000000000..d6d332b38ddb911d1208a8288554ded572eaff98 --- /dev/null +++ b/autodE/source/doc/reference/bonds.rst @@ -0,0 +1,8 @@ +***** +Bonds +***** + +.. automodule:: autode.bonds + :members: + :undoc-members: + :special-members: __init__ \ No newline at end of file diff --git a/autodE/source/doc/reference/calculation.rst b/autodE/source/doc/reference/calculation.rst new file mode 100644 index 0000000000000000000000000000000000000000..de84500791856ac00eb330137a6e1b8257e7c7af --- /dev/null +++ b/autodE/source/doc/reference/calculation.rst @@ -0,0 +1,8 @@ +*********** +Calculation +*********** + +.. automodule:: autode.calculation + :members: + :undoc-members: + :special-members: __init__ \ No newline at end of file diff --git a/autodE/source/doc/reference/config.rst b/autodE/source/doc/reference/config.rst new file mode 100644 index 0000000000000000000000000000000000000000..eb6f192daac2cc04cf4b70639ee09a89292ffdf8 --- /dev/null +++ b/autodE/source/doc/reference/config.rst @@ -0,0 +1,8 @@ +************* +Configuration +************* + +.. automodule:: autode.config + :members: + :undoc-members: + :special-members: __init__ \ No newline at end of file diff --git a/autodE/source/doc/reference/conformers/conf_gen.rst b/autodE/source/doc/reference/conformers/conf_gen.rst new file mode 100644 index 0000000000000000000000000000000000000000..7cfc62a0874169f1896aae25bf21f941c856bab6 --- /dev/null +++ b/autodE/source/doc/reference/conformers/conf_gen.rst @@ -0,0 +1,8 @@ +******** +Conf Gen +******** + +.. automodule:: autode.conformers.conf_gen + :members: + :undoc-members: + :special-members: __init__ \ No newline at end of file diff --git a/autodE/source/doc/reference/conformers/conformer.rst b/autodE/source/doc/reference/conformers/conformer.rst new file mode 100644 index 0000000000000000000000000000000000000000..8a0bef902df18fbf592d2816a6bd462b17d6ae28 --- /dev/null +++ b/autodE/source/doc/reference/conformers/conformer.rst @@ -0,0 +1,10 @@ +.. _conformer: + +********* +Conformer +********* + +.. automodule:: autode.conformers.conformer + :members: + :undoc-members: + :special-members: __init__ diff --git a/autodE/source/doc/reference/conformers/conformers.rst b/autodE/source/doc/reference/conformers/conformers.rst new file mode 100644 index 0000000000000000000000000000000000000000..627c4366048abf2c416069f11e5f3bcbe2dc0c81 --- /dev/null +++ b/autodE/source/doc/reference/conformers/conformers.rst @@ -0,0 +1,8 @@ +********** +Conformers +********** + +.. automodule:: autode.conformers.conformers + :members: + :undoc-members: + :special-members: __init__ diff --git a/autodE/source/doc/reference/conformers/index.rst b/autodE/source/doc/reference/conformers/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..8a38e399d776f58bac1f140b83e4ad93a15b3b7f --- /dev/null +++ b/autodE/source/doc/reference/conformers/index.rst @@ -0,0 +1,13 @@ +.. _conformers: + +********** +Conformers +********** + +.. toctree:: + :maxdepth: 2 + + conf_gen + conformer + conformers + diff --git a/autodE/source/doc/reference/constants.rst b/autodE/source/doc/reference/constants.rst new file mode 100644 index 0000000000000000000000000000000000000000..b204fe019b8cd511e81d55d7d44bffa82be81ae5 --- /dev/null +++ b/autodE/source/doc/reference/constants.rst @@ -0,0 +1,8 @@ +********* +Constants +********* + +.. automodule:: autode.constants + :members: + :undoc-members: + :special-members: __init__ \ No newline at end of file diff --git a/autodE/source/doc/reference/exceptions.rst b/autodE/source/doc/reference/exceptions.rst new file mode 100644 index 0000000000000000000000000000000000000000..12b623944ff32ef96c96a12f979a2994d3a662fb --- /dev/null +++ b/autodE/source/doc/reference/exceptions.rst @@ -0,0 +1,8 @@ +********** +Exceptions +********** + +.. automodule:: autode.exceptions + :members: + :undoc-members: + :special-members: __init__ \ No newline at end of file diff --git a/autodE/source/doc/reference/geom.rst b/autodE/source/doc/reference/geom.rst new file mode 100644 index 0000000000000000000000000000000000000000..b784b54f179f864def5a774887f0e9948c9b0af9 --- /dev/null +++ b/autodE/source/doc/reference/geom.rst @@ -0,0 +1,8 @@ +******** +Geometry +******** + +.. automodule:: autode.geom + :members: + :undoc-members: + :special-members: __init__ \ No newline at end of file diff --git a/autodE/source/doc/reference/hessians.rst b/autodE/source/doc/reference/hessians.rst new file mode 100644 index 0000000000000000000000000000000000000000..0430669ef70f800849d77f405698d14c216a276d --- /dev/null +++ b/autodE/source/doc/reference/hessians.rst @@ -0,0 +1,8 @@ +******** +Hessians +******** + +.. automodule:: autode.hessians + :members: + :undoc-members: + :special-members: __init__ diff --git a/autodE/source/doc/reference/index.rst b/autodE/source/doc/reference/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..2f397998378c352c3ebe51f63cbb5a04caaab5a2 --- /dev/null +++ b/autodE/source/doc/reference/index.rst @@ -0,0 +1,37 @@ +********* +Reference +********* + +.. toctree:: + :maxdepth: 1 + + atoms + bonds + calculation + conformers/index + pes/index + reactions/index + smiles/index + solvent/index + species/index + neb/index + transition_states/index + thermochemistry/index + wrappers/index + opt/index + bond_rearrangement + config + constants + exceptions + geom + input_output + log + methods + mol_graphs + plotting + point_charges + substitution + units + utils + values + hessians diff --git a/autodE/source/doc/reference/input_output.rst b/autodE/source/doc/reference/input_output.rst new file mode 100644 index 0000000000000000000000000000000000000000..e6b2e991d2e68089d1a0fc8dc1bfd91dc7adbd88 --- /dev/null +++ b/autodE/source/doc/reference/input_output.rst @@ -0,0 +1,8 @@ +************ +Input Output +************ + +.. automodule:: autode.input_output + :members: + :undoc-members: + :special-members: __init__ \ No newline at end of file diff --git a/autodE/source/doc/reference/log.rst b/autodE/source/doc/reference/log.rst new file mode 100644 index 0000000000000000000000000000000000000000..aa3847dde00fe86e1e4b2a02727f2f4a3f678ca4 --- /dev/null +++ b/autodE/source/doc/reference/log.rst @@ -0,0 +1,17 @@ +******* +Logging +******* + +.. automodule:: autode.log + :members: + :undoc-members: + :special-members: __init__ + + +Logging +------- + +To set the logging level to one of {DEBUG, INFO, WARNING, ERROR} set the AUTODE_LOG_LEVEL +environment variable, in bash:: + + $ export AUTODE_LOG_LEVEL=INFO diff --git a/autodE/source/doc/reference/methods.rst b/autodE/source/doc/reference/methods.rst new file mode 100644 index 0000000000000000000000000000000000000000..71bc6d5c02134d9ef4e25d72ede3144a483fa283 --- /dev/null +++ b/autodE/source/doc/reference/methods.rst @@ -0,0 +1,8 @@ +******* +Methods +******* + +.. automodule:: autode.methods + :members: + :undoc-members: + :special-members: __init__ \ No newline at end of file diff --git a/autodE/source/doc/reference/mol_graphs.rst b/autodE/source/doc/reference/mol_graphs.rst new file mode 100644 index 0000000000000000000000000000000000000000..a64f485b37bd8668e35acaa5f8a10849fa98f20a --- /dev/null +++ b/autodE/source/doc/reference/mol_graphs.rst @@ -0,0 +1,8 @@ +**************** +Molecular Graphs +**************** + +.. automodule:: autode.mol_graphs + :members: + :undoc-members: + :special-members: __init__ diff --git a/autodE/source/doc/reference/neb/ci.rst b/autodE/source/doc/reference/neb/ci.rst new file mode 100644 index 0000000000000000000000000000000000000000..1918c119b3b749a4b0573afad1bd7f9bdfe6d5c7 --- /dev/null +++ b/autodE/source/doc/reference/neb/ci.rst @@ -0,0 +1,8 @@ +****************** +Climbing image NEB +****************** + +.. automodule:: autode.neb.ci + :members: + :undoc-members: + :special-members: __init__ diff --git a/autodE/source/doc/reference/neb/idpp.rst b/autodE/source/doc/reference/neb/idpp.rst new file mode 100644 index 0000000000000000000000000000000000000000..82c8fa3eb4d52b9accd11134ad312d2fdf22bdee --- /dev/null +++ b/autodE/source/doc/reference/neb/idpp.rst @@ -0,0 +1,9 @@ +****************************** +Image dependent pair potential +****************************** + +.. automodule:: autode.neb.idpp + :members: + :undoc-members: + :special-members: __init__, __call__ + :private-members: _set_distance_matrices, _w diff --git a/autodE/source/doc/reference/neb/index.rst b/autodE/source/doc/reference/neb/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..78cb5f8d666855d1f339612ab5eb4ac31b6c7273 --- /dev/null +++ b/autodE/source/doc/reference/neb/index.rst @@ -0,0 +1,13 @@ +.. _neb: + +******************* +Nudged elastic band +******************* + +.. toctree:: + :maxdepth: 2 + + neb + original + ci + idpp diff --git a/autodE/source/doc/reference/neb/neb.rst b/autodE/source/doc/reference/neb/neb.rst new file mode 100644 index 0000000000000000000000000000000000000000..08253573756f653e18b5d0f7be772b564ef6c264 --- /dev/null +++ b/autodE/source/doc/reference/neb/neb.rst @@ -0,0 +1,8 @@ +********************* +NEB Transition states +********************* + +.. automodule:: autode.neb.neb + :members: + :undoc-members: + :special-members: __init__ \ No newline at end of file diff --git a/autodE/source/doc/reference/neb/original.rst b/autodE/source/doc/reference/neb/original.rst new file mode 100644 index 0000000000000000000000000000000000000000..8f1d1e9a842b5dc1d622b3bcc91051dc438dbde4 --- /dev/null +++ b/autodE/source/doc/reference/neb/original.rst @@ -0,0 +1,8 @@ +************ +Original NEB +************ + +.. automodule:: autode.neb.original + :members: + :undoc-members: + :special-members: __init__ diff --git a/autodE/source/doc/reference/opt/coordinates.rst b/autodE/source/doc/reference/opt/coordinates.rst new file mode 100644 index 0000000000000000000000000000000000000000..1067e70b5bf5052cafda7144dd0c18eb8aac1173 --- /dev/null +++ b/autodE/source/doc/reference/opt/coordinates.rst @@ -0,0 +1,50 @@ +*********** +Coordinates +*********** + +.. automodule:: autode.opt.coordinates.base + :members: + :undoc-members: + :special-members: __init__ + +| +---------- +| + + +.. automodule:: autode.opt.coordinates.cartesian + :members: + :undoc-members: + :special-members: __init__ + +| +---------- +| + + +.. automodule:: autode.opt.coordinates.dic + :members: + :undoc-members: + :special-members: __init__ + + +| +---------- +| + + +.. automodule:: autode.opt.coordinates.primitives + :members: + :undoc-members: + :special-members: __init__ + + +| +---------- +| + + +.. automodule:: autode.opt.coordinates.dimer + :members: + :undoc-members: + :special-members: __init__ diff --git a/autodE/source/doc/reference/opt/index.rst b/autodE/source/doc/reference/opt/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..8f647624e41bbf33c7972bba7ec75ec8eb09164f --- /dev/null +++ b/autodE/source/doc/reference/opt/index.rst @@ -0,0 +1,11 @@ +.. _pes: + +************ +Optimisation +************ + +.. toctree:: + :maxdepth: 2 + + coordinates + optimisers diff --git a/autodE/source/doc/reference/opt/optimisers.rst b/autodE/source/doc/reference/opt/optimisers.rst new file mode 100644 index 0000000000000000000000000000000000000000..8a666212eae78e59c4d895322c9a75a6ec4d2de4 --- /dev/null +++ b/autodE/source/doc/reference/opt/optimisers.rst @@ -0,0 +1,107 @@ +*********** +Optimisers +*********** + +.. automodule:: autode.opt.optimisers.base + :members: + :undoc-members: + :special-members: __init__ + :private-members: _step + +| + +---------- + +| + +.. automodule:: autode.opt.optimisers.rfo + :members: + :undoc-members: + :special-members: __init__ + :private-members: _step + +| + +---------- + +| + +.. automodule:: autode.opt.optimisers.prfo + :members: + :undoc-members: + :special-members: __init__ + :private-members: _step + +| + +---------- + +| + +.. automodule:: autode.opt.optimisers.steepest_decent + :members: + :undoc-members: + :special-members: __init__ + :private-members: _step + +| + +---------- + +| + +.. automodule:: autode.opt.optimisers.bfgs + :members: + :undoc-members: + :special-members: __init__ + :private-members: _step, _update_h_inv + +| + +---------- + +| + +.. automodule:: autode.opt.optimisers.line_search + :members: + :undoc-members: + :special-members: __init__ + :private-members: _step + + +| + +---------- + +| + +.. automodule:: autode.opt.optimisers.trust_region + :members: + :undoc-members: + :special-members: __init__ + :private-members: _solve_subproblem + + +| + +---------- + +| + +.. automodule:: autode.opt.optimisers.hessian_update + :members: + :undoc-members: + :special-members: __init__ + :private-members: _updated_h, _updated_h_inv + +| + +---------- + +| + +.. automodule:: autode.opt.optimisers.dimer + :members: + :undoc-members: + :special-members: __init__ + :private-members: _step diff --git a/autodE/source/doc/reference/pes/index.rst b/autodE/source/doc/reference/pes/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..ebfccedec8c9b9e7451b289d14ecf7af6f20441f --- /dev/null +++ b/autodE/source/doc/reference/pes/index.rst @@ -0,0 +1,11 @@ +.. _pes: + +*** +PES +*** + +.. toctree:: + :maxdepth: 2 + + pes + mep diff --git a/autodE/source/doc/reference/pes/mep.rst b/autodE/source/doc/reference/pes/mep.rst new file mode 100644 index 0000000000000000000000000000000000000000..ff9e7edf0d37d5684af6c5334e94a2f5dddf87a0 --- /dev/null +++ b/autodE/source/doc/reference/pes/mep.rst @@ -0,0 +1,8 @@ +****************** +Min Energy Pathway +****************** + +.. automodule:: autode.pes.mep + :members: + :undoc-members: + :special-members: __init__ diff --git a/autodE/source/doc/reference/pes/pes.rst b/autodE/source/doc/reference/pes/pes.rst new file mode 100644 index 0000000000000000000000000000000000000000..acd3a9ec79e696088a45f34cb07b96f5e369b2d7 --- /dev/null +++ b/autodE/source/doc/reference/pes/pes.rst @@ -0,0 +1,45 @@ +************************* +Potential Energy Surfaces +************************* + +.. automodule:: autode.pes.pes_nd + :members: + :undoc-members: + :special-members: __init__ + + +| + +---------- + +| + +.. automodule:: autode.pes.reactive + :members: + :undoc-members: + :special-members: __init__ + :private-members: _set_gradients + +| + +---------- + +| + +.. automodule:: autode.pes.relaxed + :members: + :undoc-members: + :special-members: __init__ + + +| + +---------- + +| + +.. automodule:: autode.pes.unrelaxed + :members: + :undoc-members: + :special-members: __init__ + diff --git a/autodE/source/doc/reference/plotting.rst b/autodE/source/doc/reference/plotting.rst new file mode 100644 index 0000000000000000000000000000000000000000..89dbb6689100dc01cca48cbabce921b06c39e0a0 --- /dev/null +++ b/autodE/source/doc/reference/plotting.rst @@ -0,0 +1,8 @@ +******** +Plotting +******** + +.. automodule:: autode.plotting + :members: + :undoc-members: + :special-members: __init__ diff --git a/autodE/source/doc/reference/point_charges.rst b/autodE/source/doc/reference/point_charges.rst new file mode 100644 index 0000000000000000000000000000000000000000..543c64a8f31debfdce1357cae6120fc9b342df6b --- /dev/null +++ b/autodE/source/doc/reference/point_charges.rst @@ -0,0 +1,8 @@ +************* +Point Charges +************* + +.. automodule:: autode.point_charges + :members: + :undoc-members: + :special-members: __init__ \ No newline at end of file diff --git a/autodE/source/doc/reference/reactions/index.rst b/autodE/source/doc/reference/reactions/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..f9a966909ccb86fa206c19a20e47aa5f04acef99 --- /dev/null +++ b/autodE/source/doc/reference/reactions/index.rst @@ -0,0 +1,12 @@ +.. _reactions: + +********* +Reactions +********* + +.. toctree:: + :maxdepth: 2 + + multistep + reaction + reaction_types diff --git a/autodE/source/doc/reference/reactions/multistep.rst b/autodE/source/doc/reference/reactions/multistep.rst new file mode 100644 index 0000000000000000000000000000000000000000..60dd8f50b8a0a3bf12cf8704e74463ca428c093b --- /dev/null +++ b/autodE/source/doc/reference/reactions/multistep.rst @@ -0,0 +1,8 @@ +****************** +Multistep Reaction +****************** + +.. automodule:: autode.reactions.multistep + :members: + :undoc-members: + :special-members: __init__ \ No newline at end of file diff --git a/autodE/source/doc/reference/reactions/reaction.rst b/autodE/source/doc/reference/reactions/reaction.rst new file mode 100644 index 0000000000000000000000000000000000000000..6cfa0683e2ae461a89c3c80364b31812cd926a9f --- /dev/null +++ b/autodE/source/doc/reference/reactions/reaction.rst @@ -0,0 +1,10 @@ +.. _reaction: + +******** +Reaction +******** + +.. automodule:: autode.reactions.reaction + :members: + :undoc-members: + :special-members: __init__ diff --git a/autodE/source/doc/reference/reactions/reaction_types.rst b/autodE/source/doc/reference/reactions/reaction_types.rst new file mode 100644 index 0000000000000000000000000000000000000000..0e11c58c333b8e802c1084da93d19a3a9801f7ab --- /dev/null +++ b/autodE/source/doc/reference/reactions/reaction_types.rst @@ -0,0 +1,8 @@ +************** +Reaction Types +************** + +.. automodule:: autode.reactions.reaction_types + :members: + :undoc-members: + :special-members: __init__ \ No newline at end of file diff --git a/autodE/source/doc/reference/smiles/angles.rst b/autodE/source/doc/reference/smiles/angles.rst new file mode 100644 index 0000000000000000000000000000000000000000..3865e19943b8c5eae1ad8c60a1eb936c5c56fab5 --- /dev/null +++ b/autodE/source/doc/reference/smiles/angles.rst @@ -0,0 +1,8 @@ +************* +SMILES Angles +************* + +.. automodule:: autode.smiles.angles + :members: + :undoc-members: + :special-members: __init__ \ No newline at end of file diff --git a/autodE/source/doc/reference/smiles/atom_types.rst b/autodE/source/doc/reference/smiles/atom_types.rst new file mode 100644 index 0000000000000000000000000000000000000000..4c84d1558a13866f84f94db652aad533ebdbaa7a --- /dev/null +++ b/autodE/source/doc/reference/smiles/atom_types.rst @@ -0,0 +1,8 @@ +***************** +SMILES Atom Types +***************** + +.. automodule:: autode.smiles.atom_types + :members: + :undoc-members: + :special-members: __init__ \ No newline at end of file diff --git a/autodE/source/doc/reference/smiles/base.rst b/autodE/source/doc/reference/smiles/base.rst new file mode 100644 index 0000000000000000000000000000000000000000..772d68665fef839e9c6af57df6f1103977d37422 --- /dev/null +++ b/autodE/source/doc/reference/smiles/base.rst @@ -0,0 +1,8 @@ +*********** +SMILES Base +*********** + +.. automodule:: autode.smiles.base + :members: + :undoc-members: + :special-members: __init__ \ No newline at end of file diff --git a/autodE/source/doc/reference/smiles/builder.rst b/autodE/source/doc/reference/smiles/builder.rst new file mode 100644 index 0000000000000000000000000000000000000000..e0e8dd28a9df63fba507e720af0e79d1a0d730c7 --- /dev/null +++ b/autodE/source/doc/reference/smiles/builder.rst @@ -0,0 +1,8 @@ +******************* +3D Geometry Builder +******************* + +.. automodule:: autode.smiles.builder + :members: + :undoc-members: + :special-members: __init__ diff --git a/autodE/source/doc/reference/smiles/index.rst b/autodE/source/doc/reference/smiles/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..ec2d6e78e1751d303eb108909ed1fe455f6c396c --- /dev/null +++ b/autodE/source/doc/reference/smiles/index.rst @@ -0,0 +1,15 @@ +.. _smiles: + +****** +SMILES +****** + +.. toctree:: + :maxdepth: 2 + + smiles + parser + builder + atom_types + angles + base diff --git a/autodE/source/doc/reference/smiles/parser.rst b/autodE/source/doc/reference/smiles/parser.rst new file mode 100644 index 0000000000000000000000000000000000000000..b859b24c7c8aa88f470258ad264a59740414caac --- /dev/null +++ b/autodE/source/doc/reference/smiles/parser.rst @@ -0,0 +1,8 @@ +************* +SMILES Parser +************* + +.. automodule:: autode.smiles.parser + :members: + :undoc-members: + :special-members: __init__ \ No newline at end of file diff --git a/autodE/source/doc/reference/smiles/smiles.rst b/autodE/source/doc/reference/smiles/smiles.rst new file mode 100644 index 0000000000000000000000000000000000000000..e8228e7ae6df8817101abc6e3823c6cc51b8050d --- /dev/null +++ b/autodE/source/doc/reference/smiles/smiles.rst @@ -0,0 +1,8 @@ +********************* +Molecules from SMILES +********************* + +.. automodule:: autode.smiles.smiles + :members: + :undoc-members: + :special-members: __init__ \ No newline at end of file diff --git a/autodE/source/doc/reference/solvent/explicit_solvent.rst b/autodE/source/doc/reference/solvent/explicit_solvent.rst new file mode 100644 index 0000000000000000000000000000000000000000..a061472de10f428a2972f7205f4c24e62970d1e8 --- /dev/null +++ b/autodE/source/doc/reference/solvent/explicit_solvent.rst @@ -0,0 +1,14 @@ +.. _explicit_solvents: + +**************** +Explicit Solvent +**************** + +.. warning:: + Explicit solvation is experimental and not implemented apart from :code:`autode.Molecule.explicitly_solvate` + + +.. automodule:: autode.solvent.explicit_solvent + :members: + :undoc-members: + :special-members: __init__, __eq__, __str__ diff --git a/autodE/source/doc/reference/solvent/index.rst b/autodE/source/doc/reference/solvent/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..217d9c8e959a0e0178bdd1b4fd231bc2578c8fba --- /dev/null +++ b/autodE/source/doc/reference/solvent/index.rst @@ -0,0 +1,11 @@ +.. _solvent: + +******* +Solvent +******* + +.. toctree:: + :maxdepth: 2 + + solvents + explicit_solvent diff --git a/autodE/source/doc/reference/solvent/solvents.rst b/autodE/source/doc/reference/solvent/solvents.rst new file mode 100644 index 0000000000000000000000000000000000000000..4a3901bba0859af38be55e326d8f3ac76d307a4f --- /dev/null +++ b/autodE/source/doc/reference/solvent/solvents.rst @@ -0,0 +1,10 @@ +.. _solvents: + +******** +Solvents +******** + +.. automodule:: autode.solvent.solvents + :members: + :undoc-members: + :special-members: __init__, __eq__, __str__ diff --git a/autodE/source/doc/reference/species/complex.rst b/autodE/source/doc/reference/species/complex.rst new file mode 100644 index 0000000000000000000000000000000000000000..6091a14a1c4e0d5e551127d4f39ceba15993dedd --- /dev/null +++ b/autodE/source/doc/reference/species/complex.rst @@ -0,0 +1,8 @@ +******* +Complex +******* + +.. automodule:: autode.species.complex + :members: + :undoc-members: + :special-members: __init__ \ No newline at end of file diff --git a/autodE/source/doc/reference/species/index.rst b/autodE/source/doc/reference/species/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..7e22355363bc6a1417a2a95fa26699ab7f41763e --- /dev/null +++ b/autodE/source/doc/reference/species/index.rst @@ -0,0 +1,12 @@ +.. _species: + +******* +Species +******* + +.. toctree:: + :maxdepth: 2 + + complex + molecule + species diff --git a/autodE/source/doc/reference/species/molecule.rst b/autodE/source/doc/reference/species/molecule.rst new file mode 100644 index 0000000000000000000000000000000000000000..573c2d83970cfc0a4624f56b14767438357e0716 --- /dev/null +++ b/autodE/source/doc/reference/species/molecule.rst @@ -0,0 +1,10 @@ +.. _molecules: + +******** +Molecule +******** + +.. automodule:: autode.species.molecule + :members: + :undoc-members: + :special-members: __init__ diff --git a/autodE/source/doc/reference/species/species.rst b/autodE/source/doc/reference/species/species.rst new file mode 100644 index 0000000000000000000000000000000000000000..1bcdb18a5553d3adb2ed29398869bdafef036544 --- /dev/null +++ b/autodE/source/doc/reference/species/species.rst @@ -0,0 +1,9 @@ +.. _species: + +******* +Species +******* + +.. automodule:: autode.species.species + :members: + :undoc-members: diff --git a/autodE/source/doc/reference/substitution.rst b/autodE/source/doc/reference/substitution.rst new file mode 100644 index 0000000000000000000000000000000000000000..f98146294a7b0daa9cbc3ffd3f17203800894b19 --- /dev/null +++ b/autodE/source/doc/reference/substitution.rst @@ -0,0 +1,8 @@ +************ +Substitution +************ + +.. automodule:: autode.substitution + :members: + :undoc-members: + :special-members: __init__ \ No newline at end of file diff --git a/autodE/source/doc/reference/thermochemistry/igm.rst b/autodE/source/doc/reference/thermochemistry/igm.rst new file mode 100644 index 0000000000000000000000000000000000000000..28f255ab1eaae304a0668e91ebfa435b2d06e90b --- /dev/null +++ b/autodE/source/doc/reference/thermochemistry/igm.rst @@ -0,0 +1,8 @@ +*************** +Ideal gas model +*************** + +.. automodule:: autode.thermochemistry.igm + :members: + :undoc-members: + :special-members: __init__ \ No newline at end of file diff --git a/autodE/source/doc/reference/thermochemistry/index.rst b/autodE/source/doc/reference/thermochemistry/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..049cab23f444adb0cb0de084e7d6523a572e84a5 --- /dev/null +++ b/autodE/source/doc/reference/thermochemistry/index.rst @@ -0,0 +1,11 @@ +.. _thermochemistry: + +*************** +Thermochemistry +*************** + +.. toctree:: + :maxdepth: 2 + + igm + symmetry diff --git a/autodE/source/doc/reference/thermochemistry/symmetry.rst b/autodE/source/doc/reference/thermochemistry/symmetry.rst new file mode 100644 index 0000000000000000000000000000000000000000..60091beeb5486475e453956e9f1f584e620a594a --- /dev/null +++ b/autodE/source/doc/reference/thermochemistry/symmetry.rst @@ -0,0 +1,8 @@ +******** +Symmetry +******** + +.. automodule:: autode.thermochemistry.symmetry + :members: + :undoc-members: + :special-members: __init__ \ No newline at end of file diff --git a/autodE/source/doc/reference/transition_states/base.rst b/autodE/source/doc/reference/transition_states/base.rst new file mode 100644 index 0000000000000000000000000000000000000000..543f3bf7dbd402015189fc78922f4d38b51da05a --- /dev/null +++ b/autodE/source/doc/reference/transition_states/base.rst @@ -0,0 +1,8 @@ +**** +Base +**** + +.. automodule:: autode.transition_states.base + :members: + :undoc-members: + :special-members: __init__ \ No newline at end of file diff --git a/autodE/source/doc/reference/transition_states/index.rst b/autodE/source/doc/reference/transition_states/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..1b03c08a03d247ccb23892561121734776055f82 --- /dev/null +++ b/autodE/source/doc/reference/transition_states/index.rst @@ -0,0 +1,16 @@ +.. _transition_states: + +***************** +Transition States +***************** + +.. toctree:: + :maxdepth: 2 + + base + locate_tss + templates + transition_state + truncation + ts_guess + diff --git a/autodE/source/doc/reference/transition_states/locate_tss.rst b/autodE/source/doc/reference/transition_states/locate_tss.rst new file mode 100644 index 0000000000000000000000000000000000000000..cfa8c7b2115bde1651942f72907dbc45f1cf225f --- /dev/null +++ b/autodE/source/doc/reference/transition_states/locate_tss.rst @@ -0,0 +1,8 @@ +********** +Locate TSs +********** + +.. automodule:: autode.transition_states.locate_tss + :members: + :undoc-members: + :special-members: __init__ \ No newline at end of file diff --git a/autodE/source/doc/reference/transition_states/templates.rst b/autodE/source/doc/reference/transition_states/templates.rst new file mode 100644 index 0000000000000000000000000000000000000000..05da7c21fa5fa1289730b3216ed4b056b0cd9c9a --- /dev/null +++ b/autodE/source/doc/reference/transition_states/templates.rst @@ -0,0 +1,8 @@ +********* +Templates +********* + +.. automodule:: autode.transition_states.templates + :members: + :undoc-members: + :special-members: __init__ \ No newline at end of file diff --git a/autodE/source/doc/reference/transition_states/transition_state.rst b/autodE/source/doc/reference/transition_states/transition_state.rst new file mode 100644 index 0000000000000000000000000000000000000000..42ebafb963ea3687999f35cc2c86720213d2b888 --- /dev/null +++ b/autodE/source/doc/reference/transition_states/transition_state.rst @@ -0,0 +1,8 @@ +**************** +Transition State +**************** + +.. automodule:: autode.transition_states.transition_state + :members: + :undoc-members: + :special-members: __init__ \ No newline at end of file diff --git a/autodE/source/doc/reference/transition_states/truncation.rst b/autodE/source/doc/reference/transition_states/truncation.rst new file mode 100644 index 0000000000000000000000000000000000000000..f04527bd8d022c4d6aaa7cb18e035dfc9c1a5634 --- /dev/null +++ b/autodE/source/doc/reference/transition_states/truncation.rst @@ -0,0 +1,8 @@ +********** +Truncation +********** + +.. automodule:: autode.transition_states.truncation + :members: + :undoc-members: + :special-members: __init__ \ No newline at end of file diff --git a/autodE/source/doc/reference/transition_states/ts_guess.rst b/autodE/source/doc/reference/transition_states/ts_guess.rst new file mode 100644 index 0000000000000000000000000000000000000000..72da75e6b1068bcdbf9e99b3f5c112142328288f --- /dev/null +++ b/autodE/source/doc/reference/transition_states/ts_guess.rst @@ -0,0 +1,8 @@ +******** +TS Guess +******** + +.. automodule:: autode.transition_states.ts_guess + :members: + :undoc-members: + :special-members: __init__ \ No newline at end of file diff --git a/autodE/source/doc/reference/units.rst b/autodE/source/doc/reference/units.rst new file mode 100644 index 0000000000000000000000000000000000000000..e5643898325936f906b483809722287dacdc8d81 --- /dev/null +++ b/autodE/source/doc/reference/units.rst @@ -0,0 +1,42 @@ +***** +Units +***** + + + + +.. automodule:: autode.units + + .. autoclass:: Unit + :members: + :undoc-members: + :special-members: __init__ + + .. autodata:: ha + .. autodata:: ev + .. autodata:: kjmol + .. autodata:: kcalmol + .. autodata:: J + .. autodata:: rad + .. autodata:: deg + .. autodata:: ang + .. autodata:: a0 + .. autodata:: nm + .. autodata:: pm + .. autodata:: m + .. autodata:: amu + .. autodata:: kg + .. autodata:: m_e + .. autodata:: amu_ang_sq + .. autodata:: ha_per_a0 + .. autodata:: ev_per_ang + .. autodata:: ha_per_ang_sq + .. autodata:: ha_per_a0_sq + .. autodata:: J_per_ang_sq + .. autodata:: J_per_m_sq + .. autodata:: J_per_ang_sq_kg + .. autodata:: wavenumber + .. autodata:: hz + .. autodata:: MB + .. autodata:: GB + .. autodata:: TB diff --git a/autodE/source/doc/reference/utils.rst b/autodE/source/doc/reference/utils.rst new file mode 100644 index 0000000000000000000000000000000000000000..bb6dbb1059698e4ceb4125442eb2c968416c1172 --- /dev/null +++ b/autodE/source/doc/reference/utils.rst @@ -0,0 +1,8 @@ +********* +Utilities +********* + +.. automodule:: autode.utils + :members: + :undoc-members: + :special-members: __init__ \ No newline at end of file diff --git a/autodE/source/doc/reference/values.rst b/autodE/source/doc/reference/values.rst new file mode 100644 index 0000000000000000000000000000000000000000..8dc7c03fabf5db9a60321ec9969cd75d3be2bb65 --- /dev/null +++ b/autodE/source/doc/reference/values.rst @@ -0,0 +1,8 @@ +****** +Values +****** + +.. automodule:: autode.values + :members: + :undoc-members: + :special-members: __init__ \ No newline at end of file diff --git a/autodE/source/doc/reference/wrappers/base.rst b/autodE/source/doc/reference/wrappers/base.rst new file mode 100644 index 0000000000000000000000000000000000000000..80297bb5865799305165fa69febee8fd13815bd2 --- /dev/null +++ b/autodE/source/doc/reference/wrappers/base.rst @@ -0,0 +1,8 @@ +**** +Base +**** + +.. automodule:: autode.wrappers.base + :members: + :undoc-members: + :special-members: __init__ \ No newline at end of file diff --git a/autodE/source/doc/reference/wrappers/g09.rst b/autodE/source/doc/reference/wrappers/g09.rst new file mode 100644 index 0000000000000000000000000000000000000000..dcf5fb82b616780ace8337a1bf3544c45156059c --- /dev/null +++ b/autodE/source/doc/reference/wrappers/g09.rst @@ -0,0 +1,8 @@ +********** +Gaussian09 +********** + +.. automodule:: autode.wrappers.G09 + :members: + :undoc-members: + :special-members: __init__ \ No newline at end of file diff --git a/autodE/source/doc/reference/wrappers/index.rst b/autodE/source/doc/reference/wrappers/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..607e02a3abee18675b2913afe651401a291fd78d --- /dev/null +++ b/autodE/source/doc/reference/wrappers/index.rst @@ -0,0 +1,18 @@ +.. _wrappers: + +******** +Wrappers +******** + +.. toctree:: + :maxdepth: 2 + + base + g09 + keywords + mopac + nwchem + orca + xtb + + diff --git a/autodE/source/doc/reference/wrappers/keywords.rst b/autodE/source/doc/reference/wrappers/keywords.rst new file mode 100644 index 0000000000000000000000000000000000000000..f79bce46433ff05df4acd44e0e4e359d77235040 --- /dev/null +++ b/autodE/source/doc/reference/wrappers/keywords.rst @@ -0,0 +1,8 @@ +******** +Keywords +******** + +.. automodule:: autode.wrappers.keywords + :members: + :undoc-members: + :special-members: __init__ \ No newline at end of file diff --git a/autodE/source/doc/reference/wrappers/mopac.rst b/autodE/source/doc/reference/wrappers/mopac.rst new file mode 100644 index 0000000000000000000000000000000000000000..74c91383f72178c2ac4dafa542443a9861b01c3f --- /dev/null +++ b/autodE/source/doc/reference/wrappers/mopac.rst @@ -0,0 +1,8 @@ +***** +MOPAC +***** + +.. automodule:: autode.wrappers.MOPAC + :members: + :undoc-members: + :special-members: __init__ \ No newline at end of file diff --git a/autodE/source/doc/reference/wrappers/nwchem.rst b/autodE/source/doc/reference/wrappers/nwchem.rst new file mode 100644 index 0000000000000000000000000000000000000000..b8449eda3132aa9bd330818855a19875705736b7 --- /dev/null +++ b/autodE/source/doc/reference/wrappers/nwchem.rst @@ -0,0 +1,8 @@ +****** +NWChem +****** + +.. automodule:: autode.wrappers.NWChem + :members: + :undoc-members: + :special-members: __init__ \ No newline at end of file diff --git a/autodE/source/doc/reference/wrappers/orca.rst b/autodE/source/doc/reference/wrappers/orca.rst new file mode 100644 index 0000000000000000000000000000000000000000..eefa75ac23f9f18f5f349915db6ba0f9100ba820 --- /dev/null +++ b/autodE/source/doc/reference/wrappers/orca.rst @@ -0,0 +1,8 @@ +**** +ORCA +**** + +.. automodule:: autode.wrappers.ORCA + :members: + :undoc-members: + :special-members: __init__ diff --git a/autodE/source/doc/reference/wrappers/xtb.rst b/autodE/source/doc/reference/wrappers/xtb.rst new file mode 100644 index 0000000000000000000000000000000000000000..26a9c1ddb75d52f1e8a8ab52e4a23677500250a1 --- /dev/null +++ b/autodE/source/doc/reference/wrappers/xtb.rst @@ -0,0 +1,8 @@ +*** +XTB +*** + +.. automodule:: autode.wrappers.XTB + :members: + :undoc-members: + :special-members: __init__ \ No newline at end of file diff --git a/autodE/source/doc/requirements.txt b/autodE/source/doc/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..ab691239598d17ab714ffa585b144ee9b380a0b8 --- /dev/null +++ b/autodE/source/doc/requirements.txt @@ -0,0 +1,2 @@ +Sphinx +sphinx-book-theme diff --git a/autodE/source/doc/troubleshooting.rst b/autodE/source/doc/troubleshooting.rst new file mode 100644 index 0000000000000000000000000000000000000000..cc84eca307fcda4d2f7fcb2deae538fd1f000967 --- /dev/null +++ b/autodE/source/doc/troubleshooting.rst @@ -0,0 +1,82 @@ +Troubleshooting +=============== + +------------ + + +Conda Solve Fails +----------------- + +If conda cannot solve the environment after attempting to install the dependencies create a new +`virtual environment `_ with:: + + $ conda create -n autode_env + $ conda activate autode_env + +then install. The **autodE** environment will need to be activated each time a new shell is opened, with: +``conda activate autode_env``. + + +------------ + +MethodUnavailable +----------------- + +If high and/or low level electronic structure methods cannot be found in your *PATH* +environment variable it is possible to set the paths manually in :code:`ade.Config` for e.g. XTB: + +.. code-block:: python + + >>> from autode import methods, Config + >>> methods.get_lmethod() + autode.exceptions.MethodUnavailable + >>> Config.XTB.path = '/path/to/xtb/bin/xtb' + >>> methods.get_lmethod() + XTB(available = True) + + +alternatively, add to the PATH environment variable e.g. in bash:: + + $ export PATH=/path/to/xtb/bin/:$PATH + + +to set this permanently, add the above line to ~/.bash_profile or ~/.bashrc on Linux. + + +------------ + + +A Transition State Cannot be Located +------------------------------------- +Automatically finding transition states (TS) with **autodE** can sometimes fail due to either +a misidentification of the TS as incorrect or an insufficiently good TS guess geometry being found. +If a TS hasn't been found first check the *reaction_name_path.png* visualisation of the initial path +and proceed depending on whether a peak is or is not present. + +Without a path peak +******************* +Oh no! without an initial peak a TS won't be able to be found. Perhaps the reaction is electronically +barrierless at this level of theory. If there are anions consider adding diffuse functions, if they are not +already present e.g.: + +.. code-block:: python + + >>> from autode import Config + >>> Config.ORCA.keywords.set_opt_basis_set('ma-def2-SVP') + +With a path peak +**************** + +If there is a peak then either (1) the TS guess geometry is not close enough to the TS for successful +optimisation. To reduce the step size: + +.. code-block:: python + + >>> from autode import Config + >>> Config.min_step_size = 0.02 + +Otherwise (2), it may be that the TS optimiser cannot locate the saddle point because of Hessian drift. Consider +reducing the number of intermediate steps between Hessian updates (i.e. `Config.ORCA.keywords.opt_ts` for ORCA). + +If you've found an interesting case where a TS cannot be found please do get in touch, we're always on the lookout +for examples to improve the method! diff --git a/autodE/source/examples/README.md b/autodE/source/examples/README.md new file mode 100644 index 0000000000000000000000000000000000000000..a87af8801ed17e0dd850c6bc297a595faf219f8e --- /dev/null +++ b/autodE/source/examples/README.md @@ -0,0 +1,7 @@ +# Examples + +See the scripts in the tutorials directory for an *autode by example* A~Z. +Starting at `a_atoms.py` read through and execute the script; note that some +require electronic structure packages to run. + +Further annotated examples can be found at [duartegroup.github.io/autodE](https://duartegroup.github.io/autodE/examples/index.html) diff --git a/autodE/source/examples/diels_alder.py b/autodE/source/examples/diels_alder.py new file mode 100644 index 0000000000000000000000000000000000000000..1ab03250684b67ccdfade2c2353250bde8ad4c0e --- /dev/null +++ b/autodE/source/examples/diels_alder.py @@ -0,0 +1,6 @@ +import autode as ade + +ade.Config.n_cores = 8 + +rxn = ade.Reaction("C=CC=C.C=C>>C1=CCCCC1", name="DA") +rxn.calculate_reaction_profile() diff --git a/autodE/source/examples/sn2.py b/autodE/source/examples/sn2.py new file mode 100644 index 0000000000000000000000000000000000000000..a8168f8dfcd585df1bc2019c6d03c151dfe7e7b9 --- /dev/null +++ b/autodE/source/examples/sn2.py @@ -0,0 +1,6 @@ +import autode as ade + +# Runs by default on 4 processing cores + +rxn = ade.Reaction("CCl.[F-]>>CF.[Cl-]", solvent_name="water") +rxn.calculate_reaction_profile() diff --git a/autodE/source/examples/tutorials/_data/Beckmann/product.xyz b/autodE/source/examples/tutorials/_data/Beckmann/product.xyz new file mode 100644 index 0000000000000000000000000000000000000000..3e076530b820548f34d0cc64cf91f09a1011cf6d --- /dev/null +++ b/autodE/source/examples/tutorials/_data/Beckmann/product.xyz @@ -0,0 +1,12 @@ +10 +Generated by autodE on: 2022-01-24. E = -172.067780 Ha +C -1.80819 0.77475 0.14587 +C -0.50690 0.21822 -0.10168 +N 0.53023 -0.22678 -0.30937 +C 1.81064 -0.77557 -0.56309 +H -2.25838 1.08634 -0.81021 +H -2.44618 0.01386 0.62302 +H -1.71275 1.64769 0.81107 +H 2.13402 -1.34176 0.32183 +H 1.74380 -1.44138 -1.43505 +H 2.51372 0.04462 -0.76599 diff --git a/autodE/source/examples/tutorials/_data/Beckmann/reactant.xyz b/autodE/source/examples/tutorials/_data/Beckmann/reactant.xyz new file mode 100644 index 0000000000000000000000000000000000000000..d0a9e70795111251b9197dabd3c6240329cd8625 --- /dev/null +++ b/autodE/source/examples/tutorials/_data/Beckmann/reactant.xyz @@ -0,0 +1,15 @@ +13 +Generated by autodE on: 2022-01-24. E = -248.297537 Ha +C -0.43661 1.26588 0.01443 +C -0.16668 -0.19208 -0.05794 +C -1.27608 -1.13699 -0.41566 +N 0.91199 -0.83825 0.14225 +O 2.08373 0.19719 0.52430 +H -0.82776 1.59762 -0.96012 +H -1.24071 1.43588 0.74751 +H 0.44034 1.86226 0.28332 +H -1.67156 -0.85449 -1.40328 +H -0.93891 -2.18081 -0.43908 +H -2.08196 -1.02644 0.32602 +H 2.78043 0.01377 -0.13798 +H 2.42387 -0.14353 1.37623 diff --git a/autodE/source/examples/tutorials/_data/Beckmann/water.xyz b/autodE/source/examples/tutorials/_data/Beckmann/water.xyz new file mode 100644 index 0000000000000000000000000000000000000000..2efd2334b95be2b280aadde817b2842c320485c8 --- /dev/null +++ b/autodE/source/examples/tutorials/_data/Beckmann/water.xyz @@ -0,0 +1,5 @@ +3 +Generated by autodE on: 2022-01-24. E = -76.276723 Ha +O -0.00031 0.39887 0.00000 +H -0.75397 -0.20002 0.00000 +H 0.75428 -0.19885 0.00000 diff --git a/autodE/source/examples/tutorials/_data/CH3_CH4.xyz b/autodE/source/examples/tutorials/_data/CH3_CH4.xyz new file mode 100644 index 0000000000000000000000000000000000000000..dbbb1ca41ffdc9f89c7e30597f3930891816a22f --- /dev/null +++ b/autodE/source/examples/tutorials/_data/CH3_CH4.xyz @@ -0,0 +1,11 @@ +9 + +C -4.11547 0.44427 0.12464 +H -3.02554 0.50585 0.30523 +H -4.36900 -0.57071 -0.24739 +H -4.40694 1.20156 -0.62986 +H -4.64921 0.63747 1.07668 +C -0.53078 1.03501 0.46130 +H -0.45586 2.12588 0.64913 +H 0.05968 0.76108 -0.43609 +H -0.16734 0.47323 1.34587 diff --git a/autodE/source/examples/tutorials/_data/DielsAlder/product.xyz b/autodE/source/examples/tutorials/_data/DielsAlder/product.xyz new file mode 100644 index 0000000000000000000000000000000000000000..b623e2cd82a24ba454c538861599a1857696cb0b --- /dev/null +++ b/autodE/source/examples/tutorials/_data/DielsAlder/product.xyz @@ -0,0 +1,25 @@ +23 +Generated by autodE on: 2022-01-24 +O 0.38025 1.55157 -2.53382 +C 0.75040 0.85165 -1.61553 +C 2.06647 1.08518 -0.98109 +C 2.49646 0.40398 0.09432 +C -0.11798 -0.26819 -1.09125 +C -1.52506 0.24759 -0.63380 +C -1.99526 -0.95649 0.19237 +C -0.80597 -0.89481 1.15866 +C -0.83308 0.57071 1.53859 +C -1.26234 1.25356 0.46698 +C 0.37814 -1.05476 0.14486 +C 1.70021 -0.65263 0.75673 +O 2.10442 -1.17846 1.77176 +H 2.66402 1.88056 -1.43625 +H 3.46292 0.61509 0.56150 +H -0.26068 -0.95042 -1.94544 +H -2.14885 0.58448 -1.46981 +H -2.95508 -0.77113 0.69566 +H -2.04920 -1.89395 -0.38378 +H -0.76316 -1.61641 1.98271 +H -0.45863 0.98012 2.47829 +H -1.31140 2.33726 0.34830 +H 0.48341 -2.12450 -0.10016 diff --git a/autodE/source/examples/tutorials/_data/DielsAlder/reactant.xyz b/autodE/source/examples/tutorials/_data/DielsAlder/reactant.xyz new file mode 100644 index 0000000000000000000000000000000000000000..d7b3d54d0a49e287e24e63dc0f4fa5a3f1430afa --- /dev/null +++ b/autodE/source/examples/tutorials/_data/DielsAlder/reactant.xyz @@ -0,0 +1,25 @@ +23 +Generated by autodE on: 2022-01-24. +O 0.63541 1.23850 -2.80236 +C 0.97802 0.63105 -1.80704 +C 1.87562 1.24491 -0.79748 +C 2.29974 0.56047 0.27634 +C 0.54701 -0.76276 -1.55285 +C -2.03374 0.54527 -0.30236 +C -2.18044 -0.76893 0.39434 +C -1.26943 -0.64220 1.57237 +C -0.73892 0.60362 1.58818 +C -1.21324 1.34095 0.42394 +C 0.97320 -1.44887 -0.47802 +C 1.89529 -0.84753 0.51247 +O 2.32000 -1.47716 1.46127 +H 2.17399 2.27815 -0.99370 +H 2.97051 0.99234 1.02371 +H -0.11487 -1.19766 -2.30592 +H -2.54078 0.81298 -1.23004 +H -3.22665 -0.92112 0.72441 +H -1.94609 -1.63421 -0.24950 +H -1.09595 -1.42924 2.30713 +H -0.05650 0.99900 2.34226 +H -0.93787 2.36841 0.18064 +H 0.68571 -2.48601 -0.28799 diff --git a/autodE/source/examples/tutorials/_data/serine.xyz b/autodE/source/examples/tutorials/_data/serine.xyz new file mode 100644 index 0000000000000000000000000000000000000000..5c52ef13190ffb82fc701af4428b746b913bdb22 --- /dev/null +++ b/autodE/source/examples/tutorials/_data/serine.xyz @@ -0,0 +1,16 @@ +14 + +N 1.78269 -0.34538 0.16004 +C 0.34419 -0.37363 0.30179 +C -0.12049 0.95264 0.88952 +O -1.51839 0.94200 0.96654 +H 0.04698 -1.18076 0.98842 +H 2.05108 0.24628 -0.62248 +H 2.18159 0.08638 0.99431 +H 0.36775 1.07246 1.87484 +H 0.25397 1.76006 0.22864 +H -1.82114 1.82563 1.19237 +C -0.36575 -0.58353 -1.02790 +O -1.36137 0.01925 -1.34232 +O 0.22532 -1.47865 -1.81810 +H 0.76143 -2.04747 -1.24290 diff --git a/autodE/source/examples/tutorials/a_atoms.py b/autodE/source/examples/tutorials/a_atoms.py new file mode 100644 index 0000000000000000000000000000000000000000..aba1a5a852c9de1738714524963e4d8ce411dc09 --- /dev/null +++ b/autodE/source/examples/tutorials/a_atoms.py @@ -0,0 +1,33 @@ +import autode as ade + +# Create a carbon atom +atom = ade.Atom("C") + +# autodE atoms have a position +print("Position: ", atom.coord) + +# and useful properties, like the atomic number. See all of these properties +# here: https://duartegroup.github.io/autodE/reference/atoms.html +print("Z: ", atom.atomic_number) + +# and can be translated by a vector, for example 1 Å along the x axis +atom.translate([1.0, 0.0, 0.0]) +print("Position: ", atom.coord) + +# or rotated +atom.rotate(axis=[0.0, 0.0, 1.0], theta=3.1415) # z axis # π radians +print("Position: ", atom.coord) + +# by default distances are in angstroms +atom.coord = [0.5, 0.0, 0.0] +print("Units are: ", atom.coord.units) + +# and can be converted to others. For example, Bohr +coord_in_a0 = atom.coord.to("bohr") +print("Coordinate:", coord_in_a0, coord_in_a0.units) + +# atoms can also be initialised at different positions +atom = ade.Atom("H", x=1.0, y=2.0, z=3.0) + +# and their representation (repr) printed +print("H atom: ", repr(atom)) diff --git a/autodE/source/examples/tutorials/b_atom_collections.py b/autodE/source/examples/tutorials/b_atom_collections.py new file mode 100644 index 0000000000000000000000000000000000000000..b88aef51bd8a216a666413147db1bf628fd0b60e --- /dev/null +++ b/autodE/source/examples/tutorials/b_atom_collections.py @@ -0,0 +1,22 @@ +from autode.atoms import Atom, Atoms + +# Atoms in autodE are lists of atom objects +atoms = Atoms([Atom("H", x=-0.4), Atom("H", x=0.4)]) + +# which have a center of mass +print("Center of mass:", atoms.com) + +# and moment of inertia properties +print("MOI matrix:", atoms.moi, sep="\n", end="\n\n") + +# vectors between atoms can be calculated. For example the vector between +# atoms 0 and 1 +print("H-H vector: ", atoms.vector(0, 1)) +# NOTE: vectors are numpy arrays + +# to test for linearity of a set of atoms +print("H2 is linear: ", atoms.are_linear()) + +# also copied and added together +h4_atoms = atoms + atoms.copy() +print("New num atoms: ", len(h4_atoms)) diff --git a/autodE/source/examples/tutorials/c_species.py b/autodE/source/examples/tutorials/c_species.py new file mode 100644 index 0000000000000000000000000000000000000000..45e6c355b945511d628686c5fda0337c1ce7d1ba --- /dev/null +++ b/autodE/source/examples/tutorials/c_species.py @@ -0,0 +1,32 @@ +import autode as ade + +# Species in autodE are atom collections with a name, +# defined charge and spin multiplicity (mult). For example, to +# generate water from its three constituent atoms +h2o = ade.Species( + name="water", + charge=0, + mult=1, + atoms=[ + ade.Atom("O"), + ade.Atom("H", x=-1.0), + ade.Atom("H", x=0.21, y=-0.97), + ], +) + +# they have a number of properties, such as mass +print("Mass(H2O): ", h2o.mass, h2o.mass.units) + +# and the chemical formula +print("Formula: ", h2o.formula) + +# as well as radii *not including any van der Walls volume* +print("Approximate radius:", round(h2o.radius, 1), h2o.radius.units) + +# with functions to calculate distances and angles between atoms +# For example, the distance between atoms 0 and 1: +print("O-H distance (Å):", h2o.distance(0, 1)) +print("H-O-H angle (º): ", h2o.angle(1, 0, 2).to("degrees")) + +# to save the structure and generate water.xyz in this directory +h2o.print_xyz_file() diff --git a/autodE/source/examples/tutorials/d_solvated_species.py b/autodE/source/examples/tutorials/d_solvated_species.py new file mode 100644 index 0000000000000000000000000000000000000000..9b420abb5e18924c798f9674621e4e663191cca6 --- /dev/null +++ b/autodE/source/examples/tutorials/d_solvated_species.py @@ -0,0 +1,27 @@ +import autode as ade + +# Solvated species can be initialised with +h2 = ade.Species( + name="h2_in_water", + charge=0, + mult=1, + atoms=[ade.Atom("H"), ade.Atom("H", 0.77)], + solvent_name="water", +) + +print("H2 is solvated in: ", h2.solvent) + +# which are by default implicit solvated +print("Is solvated implicitly:", h2.is_implicitly_solvated) + +# the associated solvent has properties, like ε +print("The dielectric is: ", h2.solvent.dielectric) + +# the solvent can be converted to explicit with +h2.explicitly_solvate(num=10) + +print("Is solvated explicitly:", h2.is_explicitly_solvated) +print("Number of water atoms: ", h2.solvent.n_atoms) + +# the whole solvated system can be printed +h2.print_xyz_file(filename="H2_solv.xyz") diff --git a/autodE/source/examples/tutorials/e_molecules.py b/autodE/source/examples/tutorials/e_molecules.py new file mode 100644 index 0000000000000000000000000000000000000000..0f961d5451521d1625d203fbe1eaba539464c1ae --- /dev/null +++ b/autodE/source/examples/tutorials/e_molecules.py @@ -0,0 +1,40 @@ +import autode as ade + +# Molecules in autodE are just like species but can +# be initialised from SMILES strings. To generate methane +methane = ade.Molecule(smiles="C") + +print( + f"Methane has {methane.n_atoms} atoms, so \n" + f"has a molecular graph with {methane.graph.number_of_nodes()}\n" + f"nodes and {methane.graph.number_of_edges()} edges (bonds)." +) + +# The whole molecule can be translated +methane.translate([1.0, 0.0, 0.0]) +print("Translated carbon position is:", methane.coordinates[0, :]) +# where the coordinates property is an Nx3 numpy array + +# and rotated +methane.rotate(axis=[0.0, 0.0, 1.0], theta=1.5) # z axis # radians +print("Rotated carbon position is: ", methane.coordinates[0, :]) + +# and calculations performed. To optimise the structure with XTB +xtb = ade.methods.XTB() +print(f"Using {ade.Config.n_cores} cores for an XTB calculation") + +if xtb.is_available: + methane.optimise(method=xtb) + print("XTB energy (Ha): ", methane.energy) + +# along with single points. For example, using ORCA +orca = ade.methods.ORCA() +print(f"Using {ade.Config.n_cores} cores for an ORCA calculation") + +if orca.is_available: + print(f"Calculating at the: [{orca.keywords.sp}] level of theory") + methane.single_point(method=orca) + print("ORCA energy (Ha): ", methane.energy) + +# with all energies available +print("All calculated energies: ", methane.energies) diff --git a/autodE/source/examples/tutorials/f_molecule_io.py b/autodE/source/examples/tutorials/f_molecule_io.py new file mode 100644 index 0000000000000000000000000000000000000000..962cb4c3e944dfaa1087687a02bd264e5ee16c30 --- /dev/null +++ b/autodE/source/examples/tutorials/f_molecule_io.py @@ -0,0 +1,34 @@ +import autode as ade + +# Molecules can be initialised directly from 3D structures +serine = ade.Molecule("_data/serine.xyz") + +# molecules initialised from .xyz files default to neutral singlets +print("Name: ", serine.name) +print("Charge: ", serine.charge) +print("Spin multiplicity: ", serine.mult) +print("Is solvated?: ", serine.solvent is not None) + +# dihedrals can also be evaluated evaluated +symbols = "-".join(serine.atomic_symbols[:4]) +print(f"{symbols} dihedral: ", serine.dihedral(0, 1, 2, 3), "radians") + +# an estimated molecular graph is initialised. +# NOTE: This will be less accurate for organometallic species +print("Bond matrix for the first 4 atoms:\n", serine.bond_matrix[:4, :4]) + +# molecules also have a has_same_connectivity_as method, which +# checks if the molecular graph is isomorphic to another +blank_mol = ade.Molecule() +print("Num atoms in a empty mol:", blank_mol.n_atoms) +print( + "Graph is isomorphic to an empty graph: ", + serine.has_same_connectivity_as(blank_mol), +) + +# Create a serine molecule from a SMILES string +serine_from_smiles = ade.Molecule(smiles="N[C@@H](CO)C(O)=O") +print( + "Graph is isomorphic to SMILES-generated molecule:", + serine.has_same_connectivity_as(serine_from_smiles), +) diff --git a/autodE/source/examples/tutorials/g_conformers.py b/autodE/source/examples/tutorials/g_conformers.py new file mode 100644 index 0000000000000000000000000000000000000000..f04c6e55006152e5917d25a4c46821be6c3d1a95 --- /dev/null +++ b/autodE/source/examples/tutorials/g_conformers.py @@ -0,0 +1,40 @@ +import autode as ade + + +# Conformers of organic molecules initalised from SMILES strings +# in autodE are generated using RDKit. For example, +pentane = ade.Molecule(smiles="CCCCC") + +print("Num. initial conformers: ", pentane.n_conformers) +print("Initial C-C distance (Å): ", pentane.distance(0, 1)) + +# To generate a set of conformers +pentane.populate_conformers(n_confs=10) + +print("Num. generated conformers: ", pentane.n_conformers) +# NOTE: the number of generated conformers is usually smaller than +# the number requested, as they are pruned based on similarity +value = ade.Config.rmsd_threshold +print("Default pruning tolerance: ", value, value.units) + +# To find the lowest energy conformer by optimising at XTB then +# re-optimising the unique ones at a higher level +xtb = ade.methods.XTB() +g09 = ade.methods.G09() + +if not (xtb.is_available and g09.is_available): + exit( + "Cannot run conformer optimisation without both an XTB " + "and Gaussian09 install" + ) + +print( + f"Generating {ade.Config.num_conformers} conformers " + f"then pruning based on energy" +) +pentane.find_lowest_energy_conformer(lmethod=xtb, hmethod=g09) + +# find_lowest_energy_conformer will set the molecule's geometry and energy +print("Optimised C-C distance (Å): ", pentane.distance(0, 1)) +print("Potential energy: ", pentane.energy, pentane.energy.units) +print("Pruned number of conformers:", pentane.n_conformers) diff --git a/autodE/source/examples/tutorials/h_configuration.py b/autodE/source/examples/tutorials/h_configuration.py new file mode 100644 index 0000000000000000000000000000000000000000..8f6385b8d18c95c5d9ebc5b37be84dcd4b0dd799 --- /dev/null +++ b/autodE/source/examples/tutorials/h_configuration.py @@ -0,0 +1,36 @@ +import autode as ade + +orca = ade.methods.ORCA() + +if not orca.is_available: + exit("This example requires an ORCA install") + +# By default, all ab-initio calculations with high-level electronic structure +# codes are performed at PBE0-D3BJ/def2-(S/TZ)VP. For example, +h2 = ade.Molecule(smiles="[H][H]") + +print(f"Using {ade.Config.n_cores} cores for the calculations") +print(f"Optimising H2 at: {orca.keywords.opt}") +h2.optimise(method=orca) # PBE0-D3BJ/def2-SVP + +print(f"Single-pointing at: {orca.keywords.sp}") +h2.single_point(method=orca) # PBE0-D3BJ/def2-TZVP + +# This can be changed by setting keywords. e.g. for an ORCA B3lYP single point +h2.single_point(method=orca, keywords=["SP", "B3LYP", "def2-TZVP"]) +print("E(B3LYP/def2-TZVP) = ", h2.energy) + +# The global configuration for all calculations using a method can be set +# with ade.Config. To set the optimisation keywords suitable for a PBE/def2-SVP +# calculation in Gaussian09 +ade.Config.G09.keywords.opt = ["PBEPBE", "Def2SVP", "integral=ultrafinegrid"] + +g09 = ade.methods.G09() +if not g09.is_available: + exit("This part requires a Gassian09 install") + +print(f"Optimising using {g09.name} at: {g09.keywords.opt}") +h2.optimise(method=g09) # Uses PBE/def2-SVP + +n2 = ade.Molecule(smiles="N#N") +n2.optimise(method=g09) # also uses PBE/def2-SVP diff --git a/autodE/source/examples/tutorials/i_constrained_opt.py b/autodE/source/examples/tutorials/i_constrained_opt.py new file mode 100644 index 0000000000000000000000000000000000000000..0dcebfcf2ae9d6572f3c6f96c977a8ddf8b4c7a9 --- /dev/null +++ b/autodE/source/examples/tutorials/i_constrained_opt.py @@ -0,0 +1,51 @@ +import autode as ade + +xtb = ade.methods.XTB() +if not xtb.is_available: + exit("This example requires an XTB install") + +# Constrained optimisations are possible by setting a molecule's constraints +# attribute, for example to calculate the relaxed PES for H-transfer from +# the neutral form of serine to the zwitterion + +serine = ade.Molecule("_data/serine.xyz", solvent_name="water") + +print("Current N-H distance (Å):", serine.distance(0, 13)) + +energies = [] +for r in (2.4, 2.2, 2.0, 1.8, 1.6, 1.4, 1.2, 1.0): + # Set the distance constraint between atoms 0 and 13 + serine.constraints.distance = {(0, 13): r} + + # optimise with XTB + serine.optimise(method=xtb) + + # and append the energies to a list + energies.append(serine.energy) + +print("Final N-H distance is: ", serine.distance(0, 13)) +print("Energies along the path:", energies) + +# Cartesian coordinates can also be fixed. For example, to optimise BH3 +# while keeping two H atoms 2 Å apart + +gaussian = ade.methods.G09() +if not gaussian.is_available: + exit("This part requires a Gaussian install") + +bh3 = ade.Molecule( + atoms=[ + ade.Atom("B", y=0.1), + ade.Atom("H", x=-1.0), + ade.Atom("H", x=1.0), + ade.Atom("H", y=1.1), + ] +) + +print("Current H-B-H angle (º): ", bh3.angle(1, 0, 2).to("º")) + +# Set the constraints and do the optimisation +bh3.constraints.cartesian = [1, 2] +bh3.optimise(method=gaussian) + +print("Optimised H-B-H angle (º):", bh3.angle(1, 0, 2).to("º")) diff --git a/autodE/source/examples/tutorials/j_NEB.py b/autodE/source/examples/tutorials/j_NEB.py new file mode 100644 index 0000000000000000000000000000000000000000..0fdca4105a307e988ce2cb751547a264007076c6 --- /dev/null +++ b/autodE/source/examples/tutorials/j_NEB.py @@ -0,0 +1,25 @@ +import multiprocessing +import autode as ade + +orca = ade.methods.ORCA() + +# Set the keywords so autodE can extract gradients at PBE/def2-SV(P) +orca.keywords.grad = ["PBE", "def2-SV(P)", "EnGrad"] + +if multiprocessing.cpu_count() < 10 or not orca.is_available: + exit("This example requires an ORCA install and 10 processing cores") + +# Nudged elastic band (NEB) calculations are available using all methods +# that support gradient evaluations (all of them!). For example, to +# set up a set of images and relax to the ~minimum energy path for a +# Diels Alder reaction between benzoquinone and cyclopentadiene +neb = ade.NEB.from_end_points( + ade.Molecule("_data/DielsAlder/reactant.xyz"), + ade.Molecule("_data/DielsAlder/product.xyz"), + num=5, +) +neb.calculate(method=orca, n_cores=10) +# will have generated a plot of the relaxation, along with a .xyz +# trajectory of the initial and final NEB path + +# To use a climbing image NEB replace ade.NEB with ade.CINEB diff --git a/autodE/source/examples/tutorials/k_1d_pes.py b/autodE/source/examples/tutorials/k_1d_pes.py new file mode 100644 index 0000000000000000000000000000000000000000..fd4a38f58c97ba72dcee86d52f1317bb9aa67080 --- /dev/null +++ b/autodE/source/examples/tutorials/k_1d_pes.py @@ -0,0 +1,24 @@ +import autode as ade + +xtb = ade.methods.XTB() +if not xtb.is_available: + exit("This example requires an XTB install") + +# One dimensional potential energy surfaces without optimising other +# coordinates can be calculated. For example, for the C-C stretch in ethane + +ethane = ade.Molecule(smiles="CC") +ethane.optimise(method=xtb) + +# Determine the initial C-C bond length for the scan +atom_idxs = (0, 1) +r = ethane.distance(*atom_idxs) - 0.05 + +# Set up the PES scan over the 0-1 distance (C-C) from r to 2.0 Å in 20 steps +pes = ade.pes.UnRelaxedPES1D(ethane, rs={atom_idxs: (r, 2.0, 20)}) +pes.calculate(method=xtb) + +print("Energies:", pes.relative_energies.to("kcal mol-1")) + +# PESs can also be plotted. To save PES.pdf in the current directory +pes.plot() diff --git a/autodE/source/examples/tutorials/l_2d_pes.py b/autodE/source/examples/tutorials/l_2d_pes.py new file mode 100644 index 0000000000000000000000000000000000000000..471b39c445b3b3334c442414efd7da0f2d176552 --- /dev/null +++ b/autodE/source/examples/tutorials/l_2d_pes.py @@ -0,0 +1,28 @@ +import autode as ade + +orca = ade.methods.ORCA() +if not orca.is_available: + exit("This example requires a ORCA install") + +# 2+ dimensional PESs can also be calculated. For example, considering the +# identity reaction CH3 + CH4 -> CH4 + CH3 +reactive_complex = ade.Molecule("_data/CH3_CH4.xyz", mult=2) + +# Create then calculate the PES +pes = ade.pes.RelaxedPESnD( + reactive_complex, + rs={(0, 1): (3.0, 8), (5, 1): (1.1, 8)}, # Current->3.0 Å in 8 steps +) # Current->1.1 Å in 8 steps +pes.calculate( + method=orca, + keywords=["LooseOpt", "PBE", "def2-SV(P)"], + n_cores=10, # Fast DFT +) # Using 10 processing cores + +# and plot the 2D surface +pes.plot(filename="CH3_CH4.pdf") + +# To plot the surface with interpolation rerun this script with: +# pes.plot(filename='CH3_CH4.pdf', +# interp_factor=3) +# NOTE: the calculations will be skipped diff --git a/autodE/source/examples/tutorials/m_thermochem.py b/autodE/source/examples/tutorials/m_thermochem.py new file mode 100644 index 0000000000000000000000000000000000000000..1f439fa03863b51e52d19615d100d35a129bf988 --- /dev/null +++ b/autodE/source/examples/tutorials/m_thermochem.py @@ -0,0 +1,27 @@ +import autode as ade + +g09 = ade.methods.G09() + +if not g09.is_available: + exit("This example requires a Gaussian09 install") + +# Create and optimise an ammonia molecule +nh3 = ade.Molecule(smiles="N") +nh3.optimise(method=g09) + +# Calculate the thermochemistry by running a Hessian calculation at the +# default level of theory +nh3.calc_thermo(method=g09) + +print("Zero-point energy =", nh3.zpe.to("kJ mol-1"), "kJ mol-1") +print("Enthalpy contribution =", nh3.h_cont) +print("Free energy contribution =", nh3.g_cont) +print("Total free energy =", nh3.free_energy) + +print("Frequencies:", [freq.to("cm-1") for freq in nh3.vib_frequencies]) + +# Frequencies have a is_imaginary property. To print the number of imaginary-s: +print( + "Number of imaginary frequencies:", + sum(freq.is_imaginary for freq in nh3.vib_frequencies), +) diff --git a/autodE/source/examples/tutorials/n_normal_modes.py b/autodE/source/examples/tutorials/n_normal_modes.py new file mode 100644 index 0000000000000000000000000000000000000000..0021511e45abd7a9d15d4055e683180004a1990b --- /dev/null +++ b/autodE/source/examples/tutorials/n_normal_modes.py @@ -0,0 +1,26 @@ +import autode as ade + +orca = ade.methods.ORCA() +if not orca.is_available: + exit("ORCA must be available for this example") + +# Optimise and calculate the Hessian for a water molecule +h2o = ade.Molecule(smiles="O") +h2o.optimise(method=orca, keywords="Opt BP86 def2-SV(P)") +h2o.calc_hessian(method=orca, keywords="Freq BP86 def2-SV(P)") + +print("Number of total frequencies is: ", 3 * h2o.n_atoms) +print("Number of vibrational frequencies is:", len(h2o.vib_frequencies)) +print( + "Frequencies in wave-numbers: ", + [float(nu) for nu in h2o.vib_frequencies], +) + +# Now, generate a set of normal mode-displaced h2o molecules displaced along +# the highest frequency normal mode (index 8), where 0-2 are translations +# 3-5 are rotations and (6, 7, 8) are vibrations +mode = h2o.normal_mode(8) + +for i in range(30): + h2o.coordinates += 0.01 * mode + h2o.print_xyz_file(filename="h2o_mode2.xyz", append=True) diff --git a/autodE/source/examples/tutorials/o_transition_states.py b/autodE/source/examples/tutorials/o_transition_states.py new file mode 100644 index 0000000000000000000000000000000000000000..82f52d5a68e60667b4ad13d1521e3c3088ec8b59 --- /dev/null +++ b/autodE/source/examples/tutorials/o_transition_states.py @@ -0,0 +1,33 @@ +import autode as ade + +# Use ORCA DFT optimisations +ade.Config.lcode = "xtb" +ade.Config.hcode = "orca" + +if not (ade.methods.ORCA().is_available and ade.methods.XTB().is_available): + exit("This example requires an ORCA and XTB install") + +# Use 8 cores for the calculations +ade.Config.n_cores = 8 + +# Locating transition states (TSs) in autodE requires defining a reaction. +# For example, the TS for a key step in a Beckmann rearrangement can be +# calculated with +r1 = ade.Reactant("_data/Beckmann/reactant.xyz", charge=1) +p1 = ade.Product("_data/Beckmann/product.xyz", charge=1) +p2 = ade.Product("_data/Beckmann/water.xyz") + +# Form the reaction and locate the transition state +rxn = ade.Reaction(r1, p1, p2) + +print("Locating the TS for a Beckmann rearrangement...") +rxn.locate_transition_state() + +if rxn.ts is not None: + print("TS has been found!") + print("Imaginary frequency: ", rxn.ts.imaginary_frequencies[0]) + rxn.ts.print_xyz_file(filename="TS_beckmann.xyz") + +print("Total number of found TSs:", len(rxn.tss)) +for ts in rxn.tss: + print(f"E(TS {ts.name}) = ", ts.energy) diff --git a/autodE/source/examples/tutorials/p_reaction_profile1.py b/autodE/source/examples/tutorials/p_reaction_profile1.py new file mode 100644 index 0000000000000000000000000000000000000000..9c3eb5f2c38ba93ab72a2c17004b64731221d0d9 --- /dev/null +++ b/autodE/source/examples/tutorials/p_reaction_profile1.py @@ -0,0 +1,18 @@ +import autode as ade + +ade.Config.lcode = "xtb" +ade.Config.hcode = "g09" + +if not (ade.methods.G09().is_available and ade.methods.XTB().is_available): + exit("This example requires a Gaussian09 and XTB install") + +# Full reaction profiles can be calculated by again forming a reaction +# and calling calculate_reaction_profile. Conformers will be searched, +# a TS found and single point energies evaluated. The reaction is defined a +# as a single string, with reactants and products separated by '>>' +rxn = ade.Reaction("CCl.[F-]>>CF.[Cl-]", solvent_name="water") + +print(f"Calculating the reaction profile for {rxn.reacs}->{rxn.prods}") +rxn.calculate_reaction_profile() + +print("∆E‡ =", rxn.delta("E‡").to("kcal mol-1")) diff --git a/autodE/source/examples/tutorials/q_reaction_profile2.py b/autodE/source/examples/tutorials/q_reaction_profile2.py new file mode 100644 index 0000000000000000000000000000000000000000..c129e2b0e2f4ca757002115f51c168bd5dda5b3d --- /dev/null +++ b/autodE/source/examples/tutorials/q_reaction_profile2.py @@ -0,0 +1,28 @@ +import autode as ade + +ade.Config.n_cores = 8 +ade.Config.hcode = "orca" + +if not ade.methods.ORCA().is_available: + exit("This example requires an ORCA install") + +# Use a basis set with diffuse functions this reaction +ade.Config.ORCA.keywords.set_opt_basis_set("ma-def2-SVP") +ade.Config.ORCA.keywords.sp.basis_set = "ma-def2-TZVP" + +# create a reaction for the addition of CN- to acetone and calculate +rxn = ade.Reaction("CC(C)=O.[C-]#N>>CC([O-])(C#N)C", solvent_name="water") + +# Calculating a reaction profile is also possible including the energies +# of the pre-reaction association complexes (with_complexes=True), including +# the free energy (∆G, ∆G‡) using qRRHO entropies (free_energy=True) or +# reaction and activation enthalpies (∆H, ∆H‡) using enthalpy=True +rxn.calculate_reaction_profile( + # with_complexes=False, + # free_energy=False, + # enthalpy=False +) + +print("∆E_r (kcal mol-1) = ", rxn.delta("E").to("kcal mol-1")) +print("∆E‡ (kcal mol-1) = ", rxn.delta("E‡").to("kcal mol-1")) +print("TS imaginary freq = ", rxn.ts.imaginary_frequencies[0]) diff --git a/autodE/source/examples/tutorials/r_hessians.py b/autodE/source/examples/tutorials/r_hessians.py new file mode 100644 index 0000000000000000000000000000000000000000..a75d6474bd67893934f22eef4466c833abbc0987 --- /dev/null +++ b/autodE/source/examples/tutorials/r_hessians.py @@ -0,0 +1,18 @@ +import autode as ade + +xtb, orca = ade.methods.XTB(), ade.methods.ORCA() + +if not (orca.is_available and xtb.is_available): + exit("This example requires both an ORCA and XTB install") + +# Dinitrogen molecule +n2 = ade.Molecule(smiles="N#N") + +# For both XTB and ORCA optimise to a minimum and calculate a numerical Hessian +for method in (xtb, orca): + n2.optimise(method=method) + n2.calc_hessian( + method=method, numerical=True, use_central_differences=True + ) + + print(f"Numerical frequency at {method.name}:", n2.vib_frequencies) diff --git a/autodE/source/examples/tutorials/s_logging.py b/autodE/source/examples/tutorials/s_logging.py new file mode 100644 index 0000000000000000000000000000000000000000..b85354f3eafef282b3c81820c29389ef03923d72 --- /dev/null +++ b/autodE/source/examples/tutorials/s_logging.py @@ -0,0 +1,19 @@ +import autode as ade + +# For more informative logs installing coloredlogs is recommended: +# conda install coloredlogs + + +# autodE writes logging information at the 'ERROR' level by default. To +# turn on logging export the AUTODE_LOG_LEVEL environment variable to +# one of: INFO, WARNING, ERROR + +# Will not print any log +_ = ade.Molecule(smiles="N") + +# To set the level to info in bash: +# export AUTODE_LOG_LEVEL=INFO +# then run this script again. + +# To write the log to a file set pipe the output to a file e.g. +# python s_logging.py 2> ade.log diff --git a/autodE/source/examples/tutorials/t_identity_reactions.py b/autodE/source/examples/tutorials/t_identity_reactions.py new file mode 100644 index 0000000000000000000000000000000000000000..634e36a58be7f21b4cfd4674d7e387d67988a262 --- /dev/null +++ b/autodE/source/examples/tutorials/t_identity_reactions.py @@ -0,0 +1,22 @@ +import autode as ade + +ade.Config.n_cores = 2 +ade.Config.hcode = "orca" + +if not ade.methods.ORCA().is_available: + exit("This example requires an ORCA install") + +# Identity reactions require the reactants to be somehow distinguished from the products +# so that the breaking/forming bonds can be identified. For example, for +# H-H + H -> H + H-H +rxn = ade.Reaction( + ade.Reactant(atoms=[ade.Atom("H", atom_class=1)], mult=2), + ade.Reactant(atoms=[ade.Atom("H"), ade.Atom("H", x=0.8)]), + ade.Product(atoms=[ade.Atom("H")], mult=2), + ade.Product(atoms=[ade.Atom("H"), ade.Atom("H", atom_class=1, x=0.8)]), +) +rxn.calculate_reaction_profile() + +print("∆E_r (kcal mol-1) = ", rxn.delta("E").to("kcal mol-1")) +print("∆E‡ (kcal mol-1) = ", rxn.delta("E‡").to("kcal mol-1")) +print("TS imaginary freq = ", rxn.ts.imaginary_frequencies[0]) diff --git a/autodE/source/examples/tutorials/u_reaction_profile_reload.py b/autodE/source/examples/tutorials/u_reaction_profile_reload.py new file mode 100644 index 0000000000000000000000000000000000000000..87cb3e24c8544b6bd2ec98ce3c0c761eeeaf9e8a --- /dev/null +++ b/autodE/source/examples/tutorials/u_reaction_profile_reload.py @@ -0,0 +1,23 @@ +import autode as ade + +ade.Config.n_cores = 4 +ade.Config.num_conformers = 1 # Use only a single conformer for speed +ade.Config.hcode = "orca" + +if not ade.methods.ORCA().is_available: + exit("This example requires an ORCA install") + +# Calculate a simple reaction profile +rxn = ade.Reaction("[F-].CCl>>CF.[Cl-]", solvent_name="water", temp=300) +rxn.calculate_reaction_profile(free_energy=True) +rxn.save("sn2_reaction.chk") + +print("∆G‡(300 K) = ", rxn.delta("G‡").to("kcal mol-1")) + +# Reactions can be reloaded from checkpoints and e.g. the thermal cont. recalculated +# without recalculating any energies using external methods +reloaded_rxn = ade.Reaction.from_checkpoint("sn2_reaction.chk") +reloaded_rxn.temp = 400 # Set the new temperature to 400 K +reloaded_rxn.calculate_thermochemical_cont() + +print("∆G‡(400 K) = ", reloaded_rxn.delta("G‡").to("kcal mol-1")) diff --git a/autodE/source/pyproject.toml b/autodE/source/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..2ae78f3a5857f97dd48b0ca3d65a8cce55f53bb5 --- /dev/null +++ b/autodE/source/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["setuptools", "cython"] +build-backend = "setuptools.build_meta" + +[tool.black] +line-length = 79 +target-version = ['py39'] + +[tool.coverage.run] +omit = [ + "setup.py", + "benchmark.py" +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if\\s+(typing\\.)?TYPE_CHECKING:" +] + +[tool.mypy] +# disallow_untyped_defs = true diff --git a/autodE/source/requirements.txt b/autodE/source/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..ef35ea75d9741df664f9ceebc46bcde507b42360 --- /dev/null +++ b/autodE/source/requirements.txt @@ -0,0 +1,8 @@ +rdkit +numpy +networkx +matplotlib +pillow>=9.5.0 +cython +scipy +loky diff --git a/autodE/source/setup.py b/autodE/source/setup.py new file mode 100644 index 0000000000000000000000000000000000000000..f46ac58dbd2d75d00dc7180bfd68bd6aa0cf0096 --- /dev/null +++ b/autodE/source/setup.py @@ -0,0 +1,69 @@ +from setuptools import setup +from setuptools.extension import Extension +from Cython.Build import cythonize +import platform + +if platform.system() == "Windows": # compile with Visual C/C++ + cpp_compile_args = ["-permissive-", "-O2"] + cpp_link_args = [] +else: # on Linux or MacOS + cpp_compile_args = ["-std=c++11", "-Wno-missing-braces", "-O3"] + cpp_link_args = ["-std=c++11"] + +extensions = [ + Extension("cconf_gen", ["autode/conformers/cconf_gen.pyx"]), + Extension( + "ade_dihedrals", + sources=["autode/ext/ade_dihedrals.pyx"], + include_dirs=["autode/ext/include"], + language="c++", + extra_compile_args=cpp_compile_args, + extra_link_args=cpp_link_args, + ), + Extension( + "ade_rb_opt", + sources=["autode/ext/ade_rb_opt.pyx"], + include_dirs=["autode/ext/include"], + language="c++", + extra_compile_args=cpp_compile_args, + extra_link_args=cpp_link_args, + ), +] + +setup( + name="autode", + version="1.4.5", + python_requires=">3.8", + packages=[ + "autode", + "autode.bracket", + "autode.conformers", + "autode.calculations", + "autode.pes", + "autode.path", + "autode.neb", + "autode.opt", + "autode.opt.coordinates", + "autode.opt.optimisers", + "autode.reactions", + "autode.smiles", + "autode.species", + "autode.wrappers", + "autode.wrappers.keywords", + "autode.thermochemistry", + "autode.transition_states", + "autode.log", + "autode.solvent", + ], + include_package_data=True, + package_data={ + "autode.transition_states": ["lib/*.txt"], + "autode.solvent": ["lib/*.xyz"], + }, + extras_require={"dev": ["black", "pre-commit"]}, + ext_modules=cythonize(extensions, language_level="3"), + url="https://github.com/duartegroup/autodE", + license="MIT", + author="autodE contributors", + description="Automated reaction profile generation", +) diff --git a/autodE/source/tests/README.md b/autodE/source/tests/README.md new file mode 100644 index 0000000000000000000000000000000000000000..2fa5b33ee94cf295bec954b55ff43ae0fcbc95a4 --- /dev/null +++ b/autodE/source/tests/README.md @@ -0,0 +1,51 @@ +### Running + +To run the tests in this directory ensure the requirements are satisfied + +```bash +conda install --file requirements.txt +``` + +then run the tests + +```bash +py.test +``` + +*** +### Benchmark +In addition to the tests there is a benchmark of calculations (*benchmark.py*) to +run for every minor release. This benchmark **must** be run and the results +posted below to ensure consistent functionality - it should take a few hours +on 8 cores. + +#### SO (small organic) +``` +Name v_imag / cm-1 Time / min Success +------------------------------------------------- +SN2 -501.4 1.8 ✓ +cope -555.0 9.7 ✓ +DA -488.8 22.8 ✓ +Hshift -1901.4 3.6 ✓ +C2N2O -492.7 2.5 ✓ +cycbut -740.6 13.7 ✓ +DAcpd -465.0 8.9 ✓ +ethCF2 -376.6 14.3 ✓ +ene -972.8 72.8 ✓ +HFloss -1801.7 43.3 ✓ +oxir -555.7 9.7 ✓ +Ocope -522.9 6.6 ✓ +SO2loss -322.0 117.9 ✓ +aldol -242.5 30.3 ✓ +dipolar -444.1 13.1 ✓ +``` + +#### SM (small metal) +``` +Name v_imag / cm-1 Time / min Success +------------------------------------------------- +hydroform1 -436.1 27.5 ✓ +MnInsert -293.3 71.9 ✓ +grubbs -107.8 122.2 ✓ +vaskas -93.3 87.1 ✓ +``` diff --git a/autodE/source/tests/__init__.py b/autodE/source/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/autodE/source/tests/benchmark.py b/autodE/source/tests/benchmark.py new file mode 100644 index 0000000000000000000000000000000000000000..09e4b4d73086d9a4875b0a6955b41d9086e80eb1 --- /dev/null +++ b/autodE/source/tests/benchmark.py @@ -0,0 +1,327 @@ +""" +As some electronic structure packages cannot be run in a CI environment this +is a benchmark of full reactions that should be checked before making a major +or minor release +""" +import os +import argparse +import autode as ade +from time import time + +here = os.path.dirname(os.path.abspath(__file__)) +data_path = os.path.join(here, "data", "benchmark") + + +# Leave unchanged for comparable timings +ade.Config.n_cores = 8 +ade.Config.freq_scale_factor = 1.0 +ade.Config.ts_template_folder_path = here + +# H2 addition to Vaska's complex has a very shallow barrier, so reduce the +# default minimum imaginary frequency for a true TS +ade.Config.min_imag_freq = -10 + + +def get_args(): + parser = argparse.ArgumentParser() + parser.add_argument( + "-a", "--all", action="store_true", help="Run all the benchmark sets" + ) + + parser.add_argument( + "-so", + "--smallorganic", + action="store_true", + help="Run the small organic benchmark set", + ) + + parser.add_argument( + "-sm", + "--smallmetal", + action="store_true", + help="Run the small metal/organometallic benchmark set", + ) + + return parser.parse_args() + + +def reactions_in_args(): + """Generate autodE reactions from arguments""" + + def add_smiles_rxns_from_file(filename): + """Add reactions from a file with lines in the format: + name XX.YY>>ZZ""" + + with open(filename, "r") as rxn_file: + for line in rxn_file: + solvent = None if len(line.split()) < 3 else line.split()[2] + rxn = ade.Reaction( + smiles=line.split()[1], + name=line.split()[0], + solvent_name=solvent, + ) + reactions.append(rxn) + + return None + + def add_xyz_rxns_from_file(filename): + """Add reactions from a file with lines in the format: + name XX.YY>>ZZ""" + + with open(filename, "r") as rxn_file: + for line in rxn_file: + name, rxn_str = line.split() + reac_names, prod_names = rxn_str.split(">>") + reacs = [ + ade.Reactant(os.path.join(data_path, f"{name}.xyz")) + for name in reac_names.split(".") + ] + prods = [ + ade.Product(os.path.join(data_path, f"{name}.xyz")) + for name in prod_names.split(".") + ] + + rxn = ade.Reaction(*reacs, *prods, name=name) + reactions.append(rxn) + + return None + + reactions = [] + + if args.smallorganic or args.all: + add_smiles_rxns_from_file(os.path.join(data_path, "ADE_SO.txt")) + + if args.smallmetal or args.all: + add_xyz_rxns_from_file(os.path.join(data_path, "ADE_SM.txt")) + + if len(reactions) == 0: + raise StopIteration( + "Had no reactions to enumerate. Call this script " + "with e.g. --smallorganic. Run " + "*python benchmark.py --help* for all options" + ) + return reactions + + +if __name__ == "__main__": + args = get_args() + out_file = open( + f"autode_benchmark_" f'{"so" if args.smallorganic else "sm"}.txt', "w" + ) + + print(f"Name v_imag / cm-1 Time / min Success", file=out_file) + for reaction in reactions_in_args(): + start_time = time() + + # Work in a separate directory for neatness + if not os.path.exists(reaction.name): + os.mkdir(reaction.name) + os.chdir(reaction.name) + reaction.locate_transition_state() + os.chdir("..") + + if reaction.ts is not None: + freq = reaction.ts.imaginary_frequencies[0] + else: + freq = 0 + + print( + f"{reaction.name:<15}" + f"{freq:<15.1f}" + f"{(time()- start_time)/60:<15.1f}" + f'{"✓" if freq < -50 else "✗"}', + file=out_file, + ) + + +""" +=============================================================================== +1.4.0 + +hydroform1 -436.1 27.5 ✓ +MnInsert -293.3 71.9 ✓ +grubbs -107.8 122.2 ✓ +vaskas -93.3 87.1 ✓ + +SN2 -501.4 1.8 ✓ +cope -555.0 9.7 ✓ +DA -488.8 22.8 ✓ +Hshift -1901.4 3.6 ✓ +C2N2O -492.7 2.5 ✓ +cycbut -740.6 13.7 ✓ +DAcpd -465.0 8.9 ✓ +ethCF2 -376.6 14.3 ✓ +ene -972.8 72.8 ✓ +HFloss -1801.7 43.3 ✓ +oxir -555.7 9.7 ✓ +Ocope -522.9 6.6 ✓ +SO2loss -322.0 117.9 ✓ +aldol -242.5 30.3 ✓ +dipolar -444.1 13.1 ✓ + +=============================================================================== +1.3.0 + +hydroform1 -418.6 32.6 ✓ +MnInsert -281.7 87.7 ✓ +grubbs -103.8 147.3 ✓ +vaskas -89.6 93.2 ✓ + +SN2 -481.7 1.7 ✓ +cope -532.8 9.7 ✓ +DA -469.2 22.1 ✓ +Hshift -1825.4 3.4 ✓ +C2N2O -472.9 2.4 ✓ +cycbut -711.0 11.9 ✓ +DAcpd -446.4 8.7 ✓ +ethCF2 -361.3 14.0 ✓ +ene -933.9 65.4 ✓ +HFloss -1729.6 37.7 ✓ +oxir -541.2 8.2 ✓ +Ocope -502.0 6.7 ✓ +SO2loss -309.0 129.3 ✓ +aldol -233.0 24.2 ✓ +dipolar -426.3 13.4 ✓ + +WARNING: Above timings are *not* comparable to the below + +=============================================================================== +1.2.0 + +Name v_imag / cm-1 Time / min Success +SN2 -496.9 1.2 ✓ +cope -557.2 7.1 ✓ +DA -484.9 19.9 ✓ +Hshift -1898.8 2.8 ✓ +C2N2O -493.7 1.8 ✓ +cycbut -741.3 13.9 ✓ +DAcpd -470.7 6.6 ✓ +ethCF2 -377.1 15.5 ✓ +ene -966.8 16.9 ✓ +HFloss -1795.6 7.4 ✓ +oxir -570.9 4.4 ✓ +Ocope -525.3 2.9 ✓ +SO2loss -324.9 26.3 ✓ +aldol -259.8 18.9 ✓ +dipolar -442.1 8.4 ✓ + + +=============================================================================== +1.1.3 + +hydroform1 -434.1 31.7 ✓ +MnInsert -302.1 68.1 ✓ +grubbs -122.6 48.2 ✓ +vaskas -94.6 39.8 ✓ + +SN2 -497.4 1.3 ✓ +cope -557.4 4.8 ✓ +DA -484.4 19.4 ✓ +Hshift -1898.9 3.0 ✓ +C2N2O -494.0 2.0 ✓ +cycbut -741.2 14.6 ✓ +DAcpd -470.8 6.9 ✓ +ethCF2 -377.5 16.0 ✓ +ene -966.9 19.7 ✓ +HFloss -1801.7 8.3 ✓ +oxir -567.7 6.4 ✓ +Ocope -525.4 3.0 ✓ +SO2loss -325.0 27.5 ✓ +aldol -259.6 19.2 ✓ +dipolar -442.1 8.2 ✓ + + +=============================================================================== +1.1.0 + +hydroform1 -434.1 38.0 ✓ +MnInsert -295.7 58.1 ✓ +grubbs -121.1 63.8 ✓ +vaskas -94.6 39.8 ✓ + +SN2 -496.8 1.3 ✓ +cope -557.3 4.7 ✓ +DA -484.4 17.6 ✓ +Hshift -1898.8 2.6 ✓ +C2N2O -493.6 1.7 ✓ +cycbut -741.1 14.3 ✓ +DAcpd -470.4 6.7 ✓ +ethCF2 -377.4 15.4 ✓ +ene -966.7 17.0 ✓ +HFloss -1801.7 7.4 ✓ +oxir -569.5 11.0 ✓ +Ocope -525.4 2.9 ✓ +SO2loss -324.1 26.4 ✓ +aldol -260.3 19.3 ✓ +dipolar -442.7 8.4 ✓ + +=============================================================================== +1.1.0dev0 + +SN2 -497.4 1.1 ✓ +cope -556.5 3.9 ✓ +DA -497.0 3.6 ✓ +Hshift -1898.6 12.3 ✓ +C2N2O -493.8 1.7 ✓ +cycbut -741.2 12.5 ✓ +DAcpd -470.9 4.6 ✓ +ethCF2 -377.7 13.6 ✓ +ene -970.5 54.1 ✓ +HFloss -1801.7 16.4 ✓ +oxir -565.3 5.9 ✓ +Ocope -553.6 3.3 ✓ +SO2loss -319.6 76.6 ✓ + +hydroform1 -433.9 44.1 ✓ +MnInsert -295.9 85.3 ✓ +grubbs -118.5 45.1 ✓ +vaskas -87.8 38.2 ✓ + +=============================================================================== +1.0.5 + +SN2 -579.3 3.5 ✓ +cope -557.4 4.8 ✓ +DA -484.2 12.7 ✓ +Hshift -1899.0 9.0 ✓ +C2N2O -493.8 2.6 ✓ +cycbut -741.1 17.7 ✓ +DAcpd -470.6 6.0 ✓ +ethCF2 -377.7 17.7 ✓ +ene -966.9 66.6 ✓ +HFloss -1801.7 33.3 ✓ +oxir -563.3 5.8 ✓ +Ocope -524.7 3.6 ✓ +SO2loss -324.2 35.2 ✓ + +=============================================================================== +1.0.1 + +SN2 -490.1 1.8 ✓ +cope -557.5 4.8 ✓ +DA -484.4 12.8 ✓ +Hshift -1899.2 9.3 ✓ +C2N2O -494.3 2.3 ✓ +cycbut -741.1 17.0 ✓ +DAcpd -470.8 6.1 ✓ +ethCF2 -377.9 17.5 ✓ +ene -967.6 65.1 ✓ +HFloss -1801.1 28.3 ✓ +oxir -559.4 57.5 ✓ +Ocope -525.4 3.7 ✓ +SO2loss -324.0 48.0 ✓ + +hydroform1 -436.3 61.1 ✓ +MnInsert -302.0 136.9 ✓ +grubbs -119.3 90.0 ✓ +vaskas -95.5 63.7 ✓ + +=============================================================================== +1.0.0a1 + + sn2 -495.9 0.1 ✓ +cope_rearr -583.3 11.3 ✓ +diels_alder -486.8 4.3 ✓ +h_shift -1897.9 2.3 ✓ +h_insert -433.1 99.8 ✓ +""" diff --git a/autodE/source/tests/conftest.py b/autodE/source/tests/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..bd1983d1ab3e7573f575c9cd5da4ec9bbd99d9cc --- /dev/null +++ b/autodE/source/tests/conftest.py @@ -0,0 +1,27 @@ +import os +import autode as ade +import pytest + + +@pytest.fixture(scope="function", autouse=True) +def autouse_fixture(): + """Fixture to execute before and after a test is run""" + + # For ORCA/Gaussian etc. calculations to be skipped there needs to be no + # attempt to make calculation names unique if they have a different input, + # so set the appropriate flag + os.environ["AUTODE_FIXUNIQUE"] = "False" + + # Run all the tests on a single core + ade.Config.n_cores = 1 + + # Frequencies are all with unity scaling + ade.Config.freq_scale_factor = 1.0 + + # Use bad quality plots for speed + ade.Config.high_quality_plots = False + + with ade.utils.temporary_config(): + yield # test happens here + + # Teardown diff --git a/autodE/source/tests/data/benchmark/ADE_SM.cdx b/autodE/source/tests/data/benchmark/ADE_SM.cdx new file mode 100644 index 0000000000000000000000000000000000000000..cd96eef4440c9d38c8db1cc298fb4f8a081dafe2 Binary files /dev/null and b/autodE/source/tests/data/benchmark/ADE_SM.cdx differ diff --git a/autodE/source/tests/data/benchmark/ADE_SM.txt b/autodE/source/tests/data/benchmark/ADE_SM.txt new file mode 100644 index 0000000000000000000000000000000000000000..3d3117a8bb11f4acc2b26ca24be92ba0bc13fcfb --- /dev/null +++ b/autodE/source/tests/data/benchmark/ADE_SM.txt @@ -0,0 +1,4 @@ +hydroform1 hydrof1r>>hydrof1p +MnInsert MnInsertr>>MnInsertp +grubbs grubbsr>>grubbsp +vaskas vask.h2>>vaskH2 \ No newline at end of file diff --git a/autodE/source/tests/data/benchmark/ADE_SO.cdx b/autodE/source/tests/data/benchmark/ADE_SO.cdx new file mode 100644 index 0000000000000000000000000000000000000000..009132e34949cbcb48d7ec1723ab5806aa1d74b4 Binary files /dev/null and b/autodE/source/tests/data/benchmark/ADE_SO.cdx differ diff --git a/autodE/source/tests/data/benchmark/ADE_SO.txt b/autodE/source/tests/data/benchmark/ADE_SO.txt new file mode 100644 index 0000000000000000000000000000000000000000..60957baa382a9a13c73b2ace982a26b4c220f6f5 --- /dev/null +++ b/autodE/source/tests/data/benchmark/ADE_SO.txt @@ -0,0 +1,15 @@ +SN2 CCl.[F-]>>FC.[Cl-] water +cope C=CC(C)CC=C>>C=CCC/C=C/C +DA C=CC=C.C=C>>C1=CCCCC1 +Hshift CC[C]([H])[H]>>C[C]([H])C +C2N2O C1CON=N1>>C=C.N#[N+][O-] +cycbut C1=CCC1>>C=CC=C +DAcpd C=C.C1=CC=CC1>>[C@H]23C=C[C@@H](C3)CC2 +ethCF2 C=C.F[C]F>>FC1(F)CC1 +ene C=C.C=CC>>CCCC=C +HFloss CCF>>C=C.[H]F +oxir [O-]C(C)=O.C1CO1>>[O-]CCOC(C)=O water +Ocope C=CCOC=C>>O=CCCC=C +SO2loss O=S1(CC=CC1)=O>>C=CC=C.O=S=O +aldol CC(C)=O.C=C([O-])C>>CC([O-])(CC(C)=O)C water +dipolar [N-]=[N+]=NC.C#CC>>CC1=CN(C)N=N1 \ No newline at end of file diff --git a/autodE/source/tests/data/benchmark/MnInsertp.xyz b/autodE/source/tests/data/benchmark/MnInsertp.xyz new file mode 100644 index 0000000000000000000000000000000000000000..914ab8a75ed77641813f4ef65af41dbd8c94a104 --- /dev/null +++ b/autodE/source/tests/data/benchmark/MnInsertp.xyz @@ -0,0 +1,17 @@ +15 + +O 1.73917 2.89622 -0.66296 +C 1.15258 1.91346 -0.64770 +Mn 0.16351 0.42913 -0.61650 +C 1.35814 -0.82634 -1.25030 +O 2.13251 -1.57688 -1.62992 +C -1.45068 1.13773 0.09559 +C -2.05523 -0.30463 -0.08095 +O -2.04300 2.06308 0.54475 +C -0.41848 0.88642 -2.30393 +O -0.77336 1.19855 -3.34232 +C 0.69170 0.15018 1.12647 +O 1.02309 -0.01000 2.20629 +H -1.36754 -1.08364 -0.49931 +H -2.38199 -0.66691 0.90182 +H -2.90183 -0.22970 -0.77540 diff --git a/autodE/source/tests/data/benchmark/MnInsertr.xyz b/autodE/source/tests/data/benchmark/MnInsertr.xyz new file mode 100644 index 0000000000000000000000000000000000000000..7870984647030eb7a9e1c7c301dcf96ba20b14ca --- /dev/null +++ b/autodE/source/tests/data/benchmark/MnInsertr.xyz @@ -0,0 +1,17 @@ +15 + +C -0.06697 -1.92539 0.54831 +Mn 0.37548 0.17840 0.79157 +C 1.15759 -0.48092 2.31231 +O 1.62308 -0.96033 3.23639 +C -0.51539 0.36419 -0.80049 +O -1.07754 0.41411 -1.79121 +C 1.93329 -0.13979 -0.13032 +O 2.90627 -0.33965 -0.68930 +C 0.76092 1.92220 1.05123 +O 0.99838 3.02703 1.22772 +C -1.22847 0.28592 1.67690 +O -2.22841 0.32164 2.22257 +H -0.80614 -2.25624 1.29037 +H -0.47623 -2.13156 -0.45072 +H 0.83948 -2.53352 0.67694 diff --git a/autodE/source/tests/data/benchmark/grubbsp.xyz b/autodE/source/tests/data/benchmark/grubbsp.xyz new file mode 100644 index 0000000000000000000000000000000000000000..0e85cb7dd7e2b489069daf0ea94216724c67c52e --- /dev/null +++ b/autodE/source/tests/data/benchmark/grubbsp.xyz @@ -0,0 +1,21 @@ +19 + + Cl 1.47700326863825 -1.72048947038917 -2.10938010149754 + Ru 1.88889210733770 -1.41131711259288 0.17105812699196 + Cl 2.30605682321098 -1.38708272827404 2.53419048761810 + C 1.47909030620422 0.47304699254016 0.35427366897020 + C 0.42279383965908 1.11441819687449 -0.49708917877154 + P 0.81612518917756 -3.36664152193346 0.88408226334245 + H 0.43402797235058 0.73793879821703 -1.52801680366843 + H 0.55076206809321 2.21220445997165 -0.49883933363797 + H -0.56776984638799 0.91218677792674 -0.05943821558084 + H 0.22157863957459 -4.27246294831731 -0.03416545467612 + H -0.28375464526609 -3.09810054157335 1.73175759161824 + H 1.58717359134013 -4.23383158800017 1.68746526026754 + H 1.49358997282940 0.82991411031731 1.39373870132430 + C 2.96293259242435 0.43692984478682 -0.23908742729551 + C 3.74419940855886 -0.90009094081709 -0.08881143892278 + H 2.94476538854638 0.76459096503584 -1.28568429263077 + H 3.48028406874658 1.13288463286422 0.43517473976762 + H 4.18698368579630 -1.24746706948399 -1.03080282173645 + H 4.39650424916586 -0.95323025715279 0.79101723851755 \ No newline at end of file diff --git a/autodE/source/tests/data/benchmark/grubbsr.xyz b/autodE/source/tests/data/benchmark/grubbsr.xyz new file mode 100644 index 0000000000000000000000000000000000000000..7c9975e1a84bbf901946dcb3ebce8a20225a72b4 --- /dev/null +++ b/autodE/source/tests/data/benchmark/grubbsr.xyz @@ -0,0 +1,21 @@ +19 +Generated by autodE on: 2021-07-06. E = -1514.610685 Ha +Cl 2.38007 -1.85335 -2.06495 +Ru 2.23539 -1.16976 0.15730 +Cl 2.60628 -1.49795 2.45063 +C 1.14349 0.28053 0.12771 +C -0.34007 0.35162 0.08494 +P 0.51179 -2.66358 0.45441 +H -0.84263 -0.57636 -0.21942 +H -0.66111 1.14673 -0.60825 +H -0.71511 0.63522 1.08703 +H -0.37390 -2.99226 -0.60469 +H -0.40815 -2.51316 1.52313 +H 0.99385 -3.96372 0.75214 +H 1.60557 1.28121 0.17148 +C 3.86130 0.45336 -0.32053 +C 4.46608 -0.69375 0.10325 +H 3.68816 0.64132 -1.38327 +H 3.70394 1.28139 0.37529 +H 4.77904 -1.45089 -0.62311 +H 4.79209 -0.81784 1.13849 diff --git a/autodE/source/tests/data/benchmark/h2.xyz b/autodE/source/tests/data/benchmark/h2.xyz new file mode 100644 index 0000000000000000000000000000000000000000..e470e7336e1cf115fde69332fda0001bf876766c --- /dev/null +++ b/autodE/source/tests/data/benchmark/h2.xyz @@ -0,0 +1,4 @@ +2 + +H 0.38056 0.00000 0.00000 +H -0.38056 0.00000 0.00000 diff --git a/autodE/source/tests/data/benchmark/hydrof1p.xyz b/autodE/source/tests/data/benchmark/hydrof1p.xyz new file mode 100644 index 0000000000000000000000000000000000000000..dbf9357733bad62becc36ba88d1826e7062db60b --- /dev/null +++ b/autodE/source/tests/data/benchmark/hydrof1p.xyz @@ -0,0 +1,16 @@ +14 +Coordinates from ORCA-job INT2_opt_orca + C -1.46481064517386 -0.63061588722056 -0.35277139730049 + C -0.43597593441125 -0.10022857180386 -1.28917438860886 + Co 0.43773250261106 0.14392516270875 0.45230064178864 + C 0.40497204762414 1.89629462817824 0.23913431130045 + O 0.47569817574348 3.01884436278751 0.03080136110747 + C 0.83926499101114 0.09894364053899 2.18230207659935 + O 1.12302012337658 0.07283140630620 3.28775555670017 + C 1.80021764285408 -0.80128533574625 -0.13140161537305 + O 2.71902363110642 -1.31962883275989 -0.57601775502999 + H -2.38343874769418 -0.03178815986354 -0.30542435279795 + H -1.71570260194692 -1.69039890689789 -0.49935755247300 + H -1.10207675542820 -0.62675185977673 0.76051221219188 + H 0.01685596034272 -0.83640174630098 -1.96172399094729 + H -0.71468038001522 0.80626009985002 -1.83703509715732 diff --git a/autodE/source/tests/data/benchmark/hydrof1r.xyz b/autodE/source/tests/data/benchmark/hydrof1r.xyz new file mode 100644 index 0000000000000000000000000000000000000000..5ac83268e6ffead658a385927c4ed84fd67fb3f3 --- /dev/null +++ b/autodE/source/tests/data/benchmark/hydrof1r.xyz @@ -0,0 +1,16 @@ +14 +Coordinates from ORCA-job INT1_opt_orca + Co 1.00383103280274 -0.14581311958276 0.26724273518964 + C -1.01501075247877 0.20702642715861 -0.01669743143521 + C -0.59392151389662 -0.81399868040055 -0.86637415966751 + C 2.34816127662439 -0.89527632326429 -0.58975408219542 + O 3.22930878864608 -1.34075017924735 -1.16369086832910 + C 0.85698658068150 -1.34310652476661 1.59683150223886 + O 0.80436784148774 -2.10263085009353 2.44384031797607 + C 1.34758206150609 1.31635795669649 1.19233397040809 + O 1.56674231775541 2.29988550035193 1.72944714142956 + H 1.23931885875088 0.83153804644525 -0.81044488143401 + H -1.57551161436634 -0.02904525878633 0.89213407577234 + H -1.16084861262198 1.21936555290979 -0.40259702331081 + H -0.41012085746125 -0.60414139486448 -1.92351510216075 + H -0.82602876742988 -1.85613956255617 -0.62889704448176 diff --git a/autodE/source/tests/data/benchmark/prop.xyz b/autodE/source/tests/data/benchmark/prop.xyz new file mode 100644 index 0000000000000000000000000000000000000000..b5dfaf659a939c6dc6581e4c6148a89da64cc442 --- /dev/null +++ b/autodE/source/tests/data/benchmark/prop.xyz @@ -0,0 +1,11 @@ +9 + +C 1.04381 0.23534 -0.29977 +C -0.08764 -0.40312 0.43477 +C -1.36696 -0.03512 0.35166 +H 1.57486 -0.49899 -0.92897 +H 0.69784 1.05397 -0.94781 +H 1.79153 0.64394 0.40078 +H 0.17440 -1.24481 1.08920 +H -1.68074 0.79746 -0.28688 +H -2.14709 -0.54868 0.91862 diff --git a/autodE/source/tests/data/benchmark/vask.xyz b/autodE/source/tests/data/benchmark/vask.xyz new file mode 100644 index 0000000000000000000000000000000000000000..f837cd560ba871c833da46e64de3f5bc47394a15 --- /dev/null +++ b/autodE/source/tests/data/benchmark/vask.xyz @@ -0,0 +1,14 @@ +12 + +Cl 1.11033 -0.45484 1.13481 +Ir 1.59804 0.02532 -1.13622 +P 2.88637 1.69202 -0.20337 +C 2.00591 0.44517 -2.86745 +O 2.27260 0.72279 -3.95558 +P 0.27413 -1.77203 -1.70356 +H 3.90020 1.25047 0.68135 +H 3.63948 2.59261 -1.00159 +H 2.21668 2.61410 0.63815 +H -0.92108 -1.57619 -2.44483 +H 0.82085 -2.83444 -2.46925 +H -0.23599 -2.48630 -0.59328 diff --git a/autodE/source/tests/data/benchmark/vaskH2.xyz b/autodE/source/tests/data/benchmark/vaskH2.xyz new file mode 100644 index 0000000000000000000000000000000000000000..f4910b483a54f3c8ef5cd4e0ab4d62e08811f90d --- /dev/null +++ b/autodE/source/tests/data/benchmark/vaskH2.xyz @@ -0,0 +1,16 @@ +14 + +Cl -0.92237 0.25494 -2.76182 +Ir 0.02877 -0.94125 -0.87849 +P 1.06847 -2.09697 -2.72655 +H -1.18664 -1.97415 -0.94301 +H -0.94676 -0.15082 0.10544 +C 0.64132 -1.83921 0.60884 +O 0.99545 -2.38614 1.55239 +P 1.52632 0.94435 -0.89840 +H 0.14476 -2.67100 -3.63032 +H 1.97272 -3.19100 -2.61561 +H 1.80602 -1.30431 -3.64257 +H 2.26172 1.20446 -2.08382 +H 2.57655 1.13504 0.04166 +H 0.87967 2.19517 -0.76904 diff --git a/autodE/source/tests/data/comp_methods.zip b/autodE/source/tests/data/comp_methods.zip new file mode 100644 index 0000000000000000000000000000000000000000..42ac9307b9d08c508a56ceec480102d537383ea1 --- /dev/null +++ b/autodE/source/tests/data/comp_methods.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca70fd29d342983ebd4f0603cc95c50a2f89b765a0f393a0edab0c0f86b9fc6d +size 11140 diff --git a/autodE/source/tests/data/complex_geoms.zip b/autodE/source/tests/data/complex_geoms.zip new file mode 100644 index 0000000000000000000000000000000000000000..74a29fdba365c30556f32f49ad5c50ba33c2a5e2 --- /dev/null +++ b/autodE/source/tests/data/complex_geoms.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:36f4324ea6bd5bb0529d6c63d6fb2b4ba797836ccaffd0b469f5dd4b41db2b6f +size 1146 diff --git a/autodE/source/tests/data/conformers.zip b/autodE/source/tests/data/conformers.zip new file mode 100644 index 0000000000000000000000000000000000000000..dd13fffdd4ca878503e005df2357a23e8a07842e --- /dev/null +++ b/autodE/source/tests/data/conformers.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1917de24bcd7ba29b561bc6793631b843c80ca447588168811cfa418b7047be1 +size 46624 diff --git a/autodE/source/tests/data/constrained_opt.zip b/autodE/source/tests/data/constrained_opt.zip new file mode 100644 index 0000000000000000000000000000000000000000..6384673753acdb33fd25e45ec774dcd1a7253952 --- /dev/null +++ b/autodE/source/tests/data/constrained_opt.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fede6380c33a736be43d8873bdc026b0c6654f13074b0f539a992667298b64cc +size 76165 diff --git a/autodE/source/tests/data/e2_tss.zip b/autodE/source/tests/data/e2_tss.zip new file mode 100644 index 0000000000000000000000000000000000000000..d832639c2e973c811c213619f58002378c52d732 --- /dev/null +++ b/autodE/source/tests/data/e2_tss.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:23f38fc507176ce240a4317c0e103755587152c64d2caa39aa89cf9849dff05c +size 1151 diff --git a/autodE/source/tests/data/free_energy_profile.zip b/autodE/source/tests/data/free_energy_profile.zip new file mode 100644 index 0000000000000000000000000000000000000000..8397e71ce44cfa86ce8035b086b8cc8941300d08 --- /dev/null +++ b/autodE/source/tests/data/free_energy_profile.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b631f568bb910515c76162b2c348cfaa573dd44ce667669aea2534d0a323c76c +size 569121 diff --git a/autodE/source/tests/data/hessians.zip b/autodE/source/tests/data/hessians.zip new file mode 100644 index 0000000000000000000000000000000000000000..1ccea3b659d8cd715787b79a0c1a36d66828505f --- /dev/null +++ b/autodE/source/tests/data/hessians.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1bb3654e0e6b031139fc56f0da6424be390fe881b30902856d6970f2a4a5f3ba +size 105279 diff --git a/autodE/source/tests/data/input_output.zip b/autodE/source/tests/data/input_output.zip new file mode 100644 index 0000000000000000000000000000000000000000..76432b3d41197938545b44b24efef82c8dc63b2e --- /dev/null +++ b/autodE/source/tests/data/input_output.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1b9e149ad2c5930d51d2c3e91a50626d3d79c9745a59ad2625265b821e6ec7d3 +size 1069 diff --git a/autodE/source/tests/data/molecule.zip b/autodE/source/tests/data/molecule.zip new file mode 100644 index 0000000000000000000000000000000000000000..2f061c5db149b4433fd67682fb4297dba8a0f01a --- /dev/null +++ b/autodE/source/tests/data/molecule.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6393a6172e0d67a40988a030cf2a79e8a446f2024aed83e2867e5e7921fa18fe +size 17208 diff --git a/autodE/source/tests/data/multistep.zip b/autodE/source/tests/data/multistep.zip new file mode 100644 index 0000000000000000000000000000000000000000..e243857a3257bb5198dcce4dd41a1175bc6e7b62 --- /dev/null +++ b/autodE/source/tests/data/multistep.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:365e14f7c8ac8c372a75e3e761455a589795631a2b78558272143fcdaebb030d +size 804353 diff --git a/autodE/source/tests/data/neb.zip b/autodE/source/tests/data/neb.zip new file mode 100644 index 0000000000000000000000000000000000000000..63087350e77034426983ad9af5d34909f86c1416 --- /dev/null +++ b/autodE/source/tests/data/neb.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:83c53d7fc2ac6d70f1b062eac95c6cc72f17b659a851032707a8f31bd0eaec35 +size 2626 diff --git a/autodE/source/tests/data/num_hess.zip b/autodE/source/tests/data/num_hess.zip new file mode 100644 index 0000000000000000000000000000000000000000..51bf09843deb09c43130471a4b4782efe7e3ad48 --- /dev/null +++ b/autodE/source/tests/data/num_hess.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8af4d7322f07d4f7de680f1df9cbfebeda18bc49fbffa2cd426c0740a8bf80ad +size 208894 diff --git a/autodE/source/tests/data/old_mlptrain.npz b/autodE/source/tests/data/old_mlptrain.npz new file mode 100644 index 0000000000000000000000000000000000000000..6890b010500d61624f3a4585a08babf7ab67880e --- /dev/null +++ b/autodE/source/tests/data/old_mlptrain.npz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d478c750d6ad1fa3eedaa2fe55f984ba97cf7826d5e221eb66f2c7f4f1a501a3 +size 17592 diff --git a/autodE/source/tests/data/pes1d.zip b/autodE/source/tests/data/pes1d.zip new file mode 100644 index 0000000000000000000000000000000000000000..3a36709d791e7a103ec7302af7fe79de39dd49dc --- /dev/null +++ b/autodE/source/tests/data/pes1d.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2f1fb127e1e83acc9f967d793c5d3d8c39be1af6022efb835c01d9e501a8f9b8 +size 119021 diff --git a/autodE/source/tests/data/pes2d.zip b/autodE/source/tests/data/pes2d.zip new file mode 100644 index 0000000000000000000000000000000000000000..d34d57ab47db5382da3023eedf33575dc7e85bce --- /dev/null +++ b/autodE/source/tests/data/pes2d.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2723dad3ec669764edb949cb2466a14d6558858d40addb94baed16f686a94410 +size 1736220 diff --git a/autodE/source/tests/data/plotting.zip b/autodE/source/tests/data/plotting.zip new file mode 100644 index 0000000000000000000000000000000000000000..21ea9b7982f1879f6094af65c029a66a1849a849 --- /dev/null +++ b/autodE/source/tests/data/plotting.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ae9616795466e1b214fc7b46b08e53e8a909c100563f6cb785ec87b3552796e4 +size 63449 diff --git a/autodE/source/tests/data/qrc.zip b/autodE/source/tests/data/qrc.zip new file mode 100644 index 0000000000000000000000000000000000000000..c5a35a7cb34d17cae27746600563187a745cb765 --- /dev/null +++ b/autodE/source/tests/data/qrc.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:96b34835d33e9ab3864170ad3b3a74a552368abe6a371bf9326424f08aa634db +size 165470 diff --git a/autodE/source/tests/data/reaction_with_complexes.zip b/autodE/source/tests/data/reaction_with_complexes.zip new file mode 100644 index 0000000000000000000000000000000000000000..a155fdb3c8246be593e33bcc87f43c930a99814c --- /dev/null +++ b/autodE/source/tests/data/reaction_with_complexes.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ebafdd16124bf912c4c13ce61045756b7935fe32a6f0a5f8820a6632ef6a742c +size 539982 diff --git a/autodE/source/tests/data/sn2prime.zip b/autodE/source/tests/data/sn2prime.zip new file mode 100644 index 0000000000000000000000000000000000000000..60f98ac7a4609ca9c53b79cb7681c78976686ba4 --- /dev/null +++ b/autodE/source/tests/data/sn2prime.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:14a6a84b155a1ad6b90c6641d96474ec00cfb33f4f7b605ccabfac65e0199b57 +size 1105 diff --git a/autodE/source/tests/data/sp_conformers.zip b/autodE/source/tests/data/sp_conformers.zip new file mode 100644 index 0000000000000000000000000000000000000000..bc2e56638b671757521b6f9f683d094302d52460 --- /dev/null +++ b/autodE/source/tests/data/sp_conformers.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b7b4a4698b04799ce4a1ac9292899374296664a6f2f63246b1a5b3e75fb23b46 +size 88071 diff --git a/autodE/source/tests/data/species.zip b/autodE/source/tests/data/species.zip new file mode 100644 index 0000000000000000000000000000000000000000..d68990e074b3ac7fb2067ffe9887c8621584b7ed --- /dev/null +++ b/autodE/source/tests/data/species.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d354919b02ab65989541c9bc62a477907dc3374e9ddeabb1df3a929c7bed49c5 +size 114802 diff --git a/autodE/source/tests/data/spline_fit.zip b/autodE/source/tests/data/spline_fit.zip new file mode 100644 index 0000000000000000000000000000000000000000..945aa8a9963289b85adbc226f6e3c616ab3b2982 --- /dev/null +++ b/autodE/source/tests/data/spline_fit.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:94bc2c9035b3eb0be71d00d5d4b4543ca09abab2bbd182df55bfb8f94e5e4ed8 +size 11256 diff --git a/autodE/source/tests/data/symm.zip b/autodE/source/tests/data/symm.zip new file mode 100644 index 0000000000000000000000000000000000000000..75b9970baab931fa523447431e8fb53b56d31108 --- /dev/null +++ b/autodE/source/tests/data/symm.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:226ff0468037da1e38669fbfb2dca6a9317b51aa53c1c0a925c4541ce57310a2 +size 2331 diff --git a/autodE/source/tests/data/test_subprocess.py b/autodE/source/tests/data/test_subprocess.py new file mode 100644 index 0000000000000000000000000000000000000000..8cde7829c178ede96040e03f17c416d15bdacd01 --- /dev/null +++ b/autodE/source/tests/data/test_subprocess.py @@ -0,0 +1 @@ +print("hello world") diff --git a/autodE/source/tests/data/thermochem.zip b/autodE/source/tests/data/thermochem.zip new file mode 100644 index 0000000000000000000000000000000000000000..95ca89e852f42295f098fba19213fb6aa423cc0b --- /dev/null +++ b/autodE/source/tests/data/thermochem.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6dbd945b12b80b2f6a7b4bd84841eb65ffb8abdfe6f139603c3cdc985db3eba0 +size 230345 diff --git a/autodE/source/tests/data/truncation.zip b/autodE/source/tests/data/truncation.zip new file mode 100644 index 0000000000000000000000000000000000000000..aafa6955d8ddae7bb7a04480df36ceb569020580 --- /dev/null +++ b/autodE/source/tests/data/truncation.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:04d9f17371d2fd6c8a2df7227a8f7d15b2463cce275ed36a9fca035e0a3931ed +size 2263 diff --git a/autodE/source/tests/requirements.txt b/autodE/source/tests/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..6e7a91e345d1668bdb2cac42ea409ee34029d820 --- /dev/null +++ b/autodE/source/tests/requirements.txt @@ -0,0 +1,4 @@ +coverage +pytest +pytest-cov +codecov \ No newline at end of file diff --git a/autodE/source/tests/test_atoms.py b/autodE/source/tests/test_atoms.py new file mode 100644 index 0000000000000000000000000000000000000000..08326565368e628a1ee0eff5d1a8f8e99349687d --- /dev/null +++ b/autodE/source/tests/test_atoms.py @@ -0,0 +1,455 @@ +import numpy as np +import pytest +from scipy.stats import special_ortho_group +from autode import atoms +from autode.atoms import Atom, DummyAtom, Atoms +from autode.values import Angle, Coordinate, Mass, Distance + + +def test_valency(): + assert Atom("C").maximal_valance == 4 + + # Default to 6 if the atom does not have a hard-coded maximum valency + assert Atom("Sc").maximal_valance == 6 + assert Atom("Rn").maximal_valance == 6 + + +def test_vdw_radius(): + assert 0.9 < Atom("H").vdw_radius < 1.2 + + # Defaults to ~2.5 Å if the van der Waals radius is unknown + assert 2 < Atom("Og").vdw_radius < 3 + + +def test_is_pi(): + assert Atom("C").is_pi(valency=3) + assert not Atom("H").is_pi(valency=1) + + assert not Atom("C").is_pi(valency=4) + assert not Atom("Sc").is_pi(valency=9) + + +def test_atoms(): + empty_atoms = Atoms() + assert "atoms" in repr(empty_atoms).lower() + assert not empty_atoms.are_linear() + assert len(empty_atoms + None) == 0 + + # Undefined COM with no atoms + with pytest.raises(ValueError): + _ = empty_atoms.com + + h_atoms = Atoms([Atom("H"), Atom("H", x=1.0)]) + assert isinstance(h_atoms.com, Coordinate) + assert np.allclose(np.asarray(h_atoms.com), np.array([0.5, 0.0, 0.0])) + assert not h_atoms.contain_metals + + v = h_atoms.vector(0, 1) + assert isinstance(v, np.ndarray) + assert np.allclose(v, np.array([1.0, 0.0, 0.0])) + + # Moment of inertia + assert np.sum(np.diag(h_atoms.moi)) > 0.0 + assert 1.9 < np.sum(np.diag(h_atoms.moi)) < 2.1 + + h_atoms_far = Atoms([Atom("H"), Atom("H", x=10.0)]) + assert np.sum(h_atoms_far.moi) > np.sum(h_atoms.moi) + + assert np.isclose(np.linalg.norm(h_atoms_far.nvector(0, 1)), 1.0) + + # COM is weighted by mass, so the x-coordinate + ch_atoms = Atoms([Atom("H"), Atom("C", x=1.0)]) + + assert 0.5 < ch_atoms.com.x < 1.0 + assert ch_atoms.com.y == 0.0 + assert ch_atoms.com.z == 0.0 + + h_and_dummy_atoms = Atoms([Atom("H"), DummyAtom(0, 0, 0)]) + assert len(h_and_dummy_atoms) == 2 + h_and_dummy_atoms.remove_dummy() + assert len(h_and_dummy_atoms) == 1 + + +def test_atoms_are_planar_simple(): + no_h_atom = Atoms([]) + h_atom = Atoms([Atom("H")]) + h_dimer = Atoms([Atom("H"), Atom("H", x=1.0)]) + h_trimer = Atoms([Atom("H"), Atom("H", x=1.0), Atom("H", y=1.0, z=0.1)]) + + for h_atoms in (no_h_atom, h_atom, h_dimer, h_trimer): + assert h_atoms.are_planar() + + +def test_c2h4_atoms_are_planar(): + c2h4_atoms = Atoms( + [ + Atom("C", -4.99490, 1.95320, 0.00000), + Atom("C", -4.74212, 0.64644, 0.00000), + Atom("H", -4.17835, 2.66796, 0.00000), + Atom("H", -6.01909, 2.31189, 0.00000), + Atom("H", -3.71793, 0.28776, -0.00000), + Atom("H", -5.55867, -0.06831, 0.00000), + ] + ) + + assert c2h4_atoms.are_planar() + + # Apply a random rotation to the set of coordinates + x = c2h4_atoms.coordinates + centre = np.average(x, axis=0) + x = np.dot(x - centre, special_ortho_group.rvs(3).T) + + for i, atom in enumerate(c2h4_atoms): + atom.coord = x[i, :] + + # Thus they should still be planar + assert c2h4_atoms.are_planar() + + +def test_atoms_are_not_planar(): + h_atoms = Atoms( + [ + Atom("H"), + Atom("H", x=1.0), + Atom("H", y=1.0, z=0.1), + Atom("H", x=2.0, y=0.2, z=0.7), + ] + ) + + assert not h_atoms.are_planar() + + with pytest.raises(Exception): + h_atoms.are_planar(distance_tol="a") + + # These atoms are planar under a very large tolerance + assert h_atoms.are_planar(distance_tol=10000.0) + assert h_atoms.are_planar(distance_tol=Distance(10000.0)) + + +def test_atom_collection_base(): + h2 = atoms.AtomCollection() + assert h2.n_atoms == 0 + assert np.isclose(h2.weight, 0.0) # 0 weight for 0 atoms + assert h2.coordinates is None + assert h2.moi is None and h2.com is None + + # Cannot set coordinates without atoms + with pytest.raises(ValueError): + h2.coordinates = np.array([1.0, 1.0, 1.0]) + + h2.atoms = [Atom("H", 0.0, 0.0, 0.0), Atom("H")] + assert h2.n_atoms == 2 + + assert h2.weight.to("amu") == 2 * atoms.atomic_weights["H"] + assert h2.mass.to("amu") == 2 * atoms.atomic_weights["H"] + + # Should be able to set coordinate from a flat array (row major) + h2.coordinates = np.zeros(shape=(6,)) + assert h2.coordinates[0] is not None + assert h2.n_atoms == 2 + + assert np.isclose(h2.distance(0, 1), 0.0, atol=1e-5) + + coord = h2.coordinates[0] + coord += 1.0 + + # Shift of coordinates should not be in place + assert not np.allclose(h2.coordinates[0], coord) + + # Cannot set coordinates with anything but a 3xn_atoms flat array, or + # 2-dimensional array (matrix) + with pytest.raises(AssertionError): + h2.coordinates = np.array([]) + + with pytest.raises(AssertionError): + h2.coordinates = np.array([1.0, 0.1]) + + with pytest.raises(AssertionError): + h2.coordinates = np.array([[[1.0], [1.0]]]) + + with pytest.raises(ValueError): + h2.distance(-1, 0) + + with pytest.raises(ValueError): + h2.distance(0, 2) + + +def test_atom_collection_angles(): + h2o = atoms.AtomCollection() + h2o.atoms = [Atom("H", x=-1.0), Atom("O"), Atom("H", x=1.0)] + + assert np.isclose(h2o.mass.to("amu"), 18, atol=0.2) + + # Should default to more human readable degree units + assert np.isclose(h2o.angle(0, 1, 2).to("deg"), 180) + assert np.isclose(h2o.angle(0, 1, 2).to("degrees"), 180) + + # No -1 atom + with pytest.raises(ValueError): + _ = h2o.angle(-1, 0, 1) + + # Angle is not defined when one vector is the zero vector + with pytest.raises(ValueError): + _ = h2o.angle(0, 0, 1) + + # Angles default to radians + assert np.isclose(np.abs(h2o.angle(0, 1, 2)), np.pi) + + with pytest.raises(TypeError): + _ = h2o.angle(0, 1, 2).to("not a unit") + + assert isinstance(h2o.angle(0, 1, 2).copy(), Angle) + + h2o.atoms[1].coord = np.array([-0.8239, -0.5450, 0.0000]) + h2o.atoms[2].coord = np.array([0.8272, -0.5443, 0.0000]) + + assert 90 < h2o.angle(0, 1, 2).to("deg") < 120 + + +def test_atom_collection_dihedral(): + h2o2 = atoms.AtomCollection() + h2o2.atoms = [ + Atom("O", -0.85156, -0.20464, 0.31961), + Atom("O", 0.41972, 0.06319, 0.10395), + Atom("H", -1.31500, 0.08239, -0.50846), + Atom("H", 0.58605, 0.91107, 0.59006), + ] + + assert np.isclose(h2o2.dihedral(2, 0, 1, 3).to("deg"), 100.8, atol=1.0) + + # Undefined dihedral with a zero vector between two atoms + with pytest.raises(ValueError): + h2o2.atoms[0].coord = np.zeros(3) + h2o2.atoms[1].coord = np.zeros(3) + + _ = h2o2.dihedral(2, 0, 1, 3) + + # and a dihedral with atoms not present in the molecule + with pytest.raises(ValueError): + _ = h2o2.dihedral(2, 0, 1, 10) + + +def test_atom_h(): + h = Atom(atomic_symbol="H", x=0.0, y=0.0, z=0.0) + assert h.label == "H" + assert h.atomic_number == 1 + assert h.atomic_symbol == "H" + assert not h.is_metal + assert h.tm_row is None + assert h.group == 1 + assert h.period == 1 + + assert len(h.coord) == 3 + assert h.coord[0] == 0 + assert h.coord[1] == 0 + assert h.coord[2] == 0 + + # Translate the H atom by 1 A in the z direction + h.translate(vec=np.array([0.0, 0.0, 1.0])) + assert np.linalg.norm(h.coord - np.array([0.0, 0.0, 1.0])) < 1e-6 + + with pytest.raises(ValueError): + h.translate(some_unkown_arg=5) + + # Rotate the atom 180° (pi radians) in the x axis + h.rotate(axis=np.array([1.0, 0.0, 0.0]), theta=np.pi) + assert np.linalg.norm(h.coord - np.array([0.0, 0.0, -1.0])) < 1e-6 + + # Perform a rotation about a different origin e.g. (1, 0, -1) + h.rotate( + axis=np.array([0.0, 0.0, 1.0]), + theta=np.pi, + origin=np.array([1.0, 0.0, -1.0]), + ) + assert np.linalg.norm(h.coord - np.array([2.0, 0.0, -1.0])) < 1e-6 + + # Ensure that the atoms has a string representation + assert len(str(h)) > 0 + + +def test_atom_other(): + assert Atom("C").atomic_number == 6 + assert Atom("C").period == 2 + assert Atom("C").group == 14 + assert 11.9 < Atom("C").weight.to("amu") < 12.1 + + dummy = atoms.DummyAtom(0.0, 0.0, 0.0) + assert dummy.atomic_number == 0 + assert dummy.period == 0 + assert dummy.group == 0 + assert dummy.mass == dummy.weight == 0.0 + + fe = Atom(atomic_symbol="Fe") + assert fe.tm_row == 1 + + # Should have a mass, even if it's estimated for all elements + for element in atoms.elements: + atom = Atom(element) + assert atom.weight is not None + + +def test_atom_coord_setting(): + atom = Atom("H", 0.0, 0.0, 0.0) + + with pytest.raises(ValueError): + atom.coord = None + + with pytest.raises(ValueError): + atom.coord = [1.0, 10] + + with pytest.raises(ValueError): + atom.coord = 1.0, 1.0 + + atom.coord = np.array([1.0, 0.0, 0.0]) + assert np.allclose(atom.coord.to("nm"), np.array([0.1, 0.0, 0.0])) + + +def test_periodic_table(): + for invalid_period in (0, 8): + with pytest.raises(ValueError): + _ = atoms.PeriodicTable.period(n=invalid_period) + + with pytest.raises(ValueError): + _ = atoms.PeriodicTable.group(n=19) # Groups don't exceed 18 + + period2 = atoms.PeriodicTable.period(n=2) + assert len(period2) == 8 + assert period2[0] == "Li" + + assert len(atoms.PeriodicTable.period(n=1)) == 2 + assert len(atoms.PeriodicTable.period(n=3)) == 8 + assert len(atoms.PeriodicTable.period(n=4)) == 18 + + group13 = atoms.PeriodicTable.group(n=13) + assert "B" in group13 + + with pytest.raises(Exception): + _ = atoms.PeriodicTable.group(0) # No group 0 + _ = atoms.PeriodicTable.group(19) # or 19 + + with pytest.raises(IndexError): + _ = atoms.PeriodicTable.element(0, 0) + _ = atoms.PeriodicTable.element(0, 3) + + with pytest.raises(Exception): + _ = atoms.PeriodicTable.transition_metals(row=0) + _ = atoms.PeriodicTable.transition_metals(row=10) + + assert "Fe" in atoms.PeriodicTable.transition_metals(row=1) + + assert atoms.PeriodicTable.element(2, 13) == "B" + + +def test_atom_doc_examples(): + """Examples that appear in doc strings. + + PLEASE update docstrings if changing these tests + """ + + assert Atom("C").atomic_number == 6 + + assert Atom("Zn").atomic_symbol == "Zn" + + assert Atom("H").coord == Coordinate(0.0, 0.0, 0.0, units="Å") + assert np.isclose(Atom("H", x=1.0).coord.x, 1.0) + assert np.allclose( + Atom("H", x=1.0, y=-1.0).coord.to("a0"), + Coordinate(1.889, -1.889, 0.0, units="bohr"), + atol=1e-3, + ) + + assert not Atom("C").is_metal + assert Atom("Zn").is_metal + + assert Atom("C").group == 14 + + assert Atom("C").period == 2 + + assert Atom("C").weight == Mass(12.0107, units="amu") + assert Atom("C").weight == Atom("C").mass + + assert Atom("H").mass.to("me") == Mass(1837.3622207894994, units="m_e") + + atom = Atom("H") + atom.translate(1.0, 0.0, 0.0) + assert atom.coord == Coordinate(1.0, 0.0, 0.0, units="Å") + + atom = Atom("H") + atom.translate(np.ones(3)) + assert atom.coord == Coordinate(1.0, 1.0, 1.0, units="Å") + atom.translate(vec=-atom.coord) + assert atom.coord == Coordinate(0.0, 0.0, 0.0, units="Å") + + atom = Atom("H", x=1.0) + atom.rotate(axis=[0.0, 0.0, 1.0], theta=3.14) + assert np.allclose( + atom.coord, Coordinate(-1, 0.0, 0.0, units="Å"), atol=1e-2 + ) + + from autode.values import Angle + + atom = Atom("H", x=1.0) + atom.rotate(axis=[0.0, 0.0, 1.0], theta=Angle(180, units="deg")) + assert np.allclose( + atom.coord, Coordinate(-1, 0.0, 0.0, units="Å"), atol=1e-5 + ) + + +def test_atoms_collection_doc_examples(): + from autode import Atom, Molecule + + h2o = Molecule(atoms=[Atom("H", x=-1), Atom("O"), Atom("H", x=1)]) + assert np.isclose(h2o.angle(0, 1, 2).to("deg"), 180, atol=1e-4) + + h2s2 = Molecule( + atoms=[ + Atom("S", 0.1527, 0.9668, -0.9288), + Atom("S", 2.0024, 0.0443, -0.4227), + Atom("H", -0.5802, 0.0234, -0.1850), + Atom("H", 2.1446, 0.8424, 0.7276), + ] + ) + + assert np.isclose(h2s2.dihedral(2, 0, 1, 3).to("deg"), -90.0, atol=0.1) + + +def test_atom_copy(): + a = atoms.Atom("H") + b = a.copy() + a.label = "C" + + assert b.label == "H" + + +def test_dummy_atom_radii(): + atom = DummyAtom(0.0, 0.0, 0.0) + assert np.isclose(atom.covalent_radius, 0.0) + assert np.isclose(atom.vdw_radius, 0.0) + + +def test_eqm_bond_distance(): + h2_atoms = Atoms([Atom("H"), Atom("H")]) + + assert np.isclose(h2_atoms.eqm_bond_distance(0, 0), 0.0) + + assert np.isclose(h2_atoms.distance(0, 1), 0.0) + assert np.isclose(h2_atoms.eqm_bond_distance(0, 1), 0.7, atol=0.1) + + # Cannot determine the distance between atoms not present in the set + with pytest.raises(ValueError): + _ = h2_atoms.eqm_bond_distance(0, 2) + + +def test_atom_equality(): + assert Atom("H") == Atom("H") + assert Atom("H") != Atom("H", partial_charge=0.1) + assert Atom("H", partial_charge=0.1) != Atom("H") + assert Atom("H", atom_class=1) != Atom("H", atom_class=0) + assert Atom("C") != Atom("H") + + +@pytest.mark.parametrize( + "element,atomic_number", [("H", 1), ("C", 6), ("F", 9), ("Cl", 17)] +) +def test_atomic_numbers(element: str, atomic_number: int): + assert Atom(element).atomic_number == atomic_number diff --git a/autodE/source/tests/test_attack.py b/autodE/source/tests/test_attack.py new file mode 100644 index 0000000000000000000000000000000000000000..678736aa3894c1f9080b9b2cdc600a127e4dbbab --- /dev/null +++ b/autodE/source/tests/test_attack.py @@ -0,0 +1,87 @@ +from autode.species.complex import ReactantComplex +from autode.species.species import Species +from autode.atoms import Atom +from autode.substitution import SubstitutionCentre +from autode.mol_graphs import make_graph +from autode.substitution import attack_cost +from autode.substitution import get_cost_rotate_translate +import numpy as np + + +nh3 = Species( + name="nh3", + charge=0, + mult=1, + atoms=[ + Atom("N", -3.13130, 0.40668, -0.24910), + Atom("H", -3.53678, 0.88567, 0.55690), + Atom("H", -3.33721, 1.03222, -1.03052), + Atom("H", -3.75574, -0.38765, -0.40209), + ], +) +make_graph(nh3) + + +ch3cl = Species( + name="CH3Cl", + charge=0, + mult=1, + atoms=[ + Atom("Cl", 1.63751, -0.03204, -0.01858), + Atom("C", -0.14528, 0.00318, 0.00160), + Atom("H", -0.49672, -0.50478, 0.90708), + Atom("H", -0.51741, -0.51407, -0.89014), + Atom("H", -0.47810, 1.04781, 0.00015), + ], +) +make_graph(ch3cl) + + +def test_attack(): + reactant = ReactantComplex(nh3, ch3cl) + subst_centre = SubstitutionCentre( + a_atom_idx=0, c_atom_idx=5, x_atom_idx=4, a_atom_nn_idxs=[1, 2, 3] + ) + subst_centre.r0_ac = 1.38 + + cost = attack_cost( + reactant=reactant, + subst_centres=[subst_centre], + attacking_mol_idx=0, + a=1, + b=1, + c=10, + d=1, + ) + + assert np.abs(cost - 2.919) < 1e-3 + + # Rotation by 2π in place, translation by 0.0 and rotation by another 2π + # should leave the cost unchanged.. + + rot_axis_inplace = [1.0, 1.0, 1.0] + rot_angle_inplace = 2 * np.pi + + translation_vec = [0.0, 0.0, 0.0] + + rot_axis = [1.0, 1.0, 1.0] + rot_angle = 2 * np.pi + + x = ( + rot_axis_inplace + + [rot_angle_inplace] + + translation_vec + + rot_axis + + [rot_angle] + ) + + cost_trans_rot = get_cost_rotate_translate( + x=np.array(x), + reactant=reactant, + subst_centres=[subst_centre], + attacking_mol_idx=0, + ) + + # Requires a=1, b=1, c=1, d=10 in the attack_cost() called by get_cost_ + # rotate_translate() + assert np.abs(cost_trans_rot - 3.5072) < 1e-3 diff --git a/autodE/source/tests/test_bond_rearrangement.py b/autodE/source/tests/test_bond_rearrangement.py new file mode 100644 index 0000000000000000000000000000000000000000..0d750702e4f9cef6486d4e2e1df3db11b09a3e0b --- /dev/null +++ b/autodE/source/tests/test_bond_rearrangement.py @@ -0,0 +1,634 @@ +import os +import pytest +import autode as ade +from autode import bond_rearrangement as br +from autode.mol_graphs import MolecularGraph +from autode.species.molecule import Molecule +from autode.bond_rearrangement import BondRearrangement, get_bond_rearrangs +from autode.species.complex import ReactantComplex, ProductComplex +from autode.atoms import Atom +from autode.mol_graphs import is_isomorphic +from autode.mol_graphs import make_graph +from autode.utils import work_in_tmp_dir + + +# Some of the 'reactions' here are not physical, hence for some the graph will +# be regenerated allowing for invalid hydrogen valencies + + +def test_prune_small_rings3(): + # Square H4 "molecule" + h4 = Molecule( + atoms=[ + Atom("H"), + Atom("H", x=0.5), + Atom("H", y=0.5), + Atom("H", x=0.5, y=0.5), + ] + ) + make_graph(h4, allow_invalid_valancies=True) + + # Some unphysical bond rearrangements + three_mem = BondRearrangement( + forming_bonds=[(0, 3)], breaking_bonds=[(1, 2)] + ) + four_mem = BondRearrangement( + forming_bonds=[(0, 1)], breaking_bonds=[(1, 2)] + ) + bond_rearrs = [three_mem, four_mem] + + ade.Config.skip_small_ring_tss = True + br.prune_small_ring_rearrs(bond_rearrs, h4) + + # Should not prune if there are different ring sizes + assert len(bond_rearrs) == 2 + + +def test_prune_small_rings2(): + reaction = ade.Reaction("CCCC=C>>C=C.C=CC") + + ade.Config.skip_small_ring_tss = False + bond_rearrs = br.get_bond_rearrangs( + reactant=reaction.reactant, + product=reaction.product, + name="tmp", + save=False, + ) + assert len(bond_rearrs) > 2 + + ade.Config.skip_small_ring_tss = True + + br.prune_small_ring_rearrs(bond_rearrs, reaction.reactant) + assert len(bond_rearrs) == 2 + + # Should find the 6-membered TS + assert bond_rearrs[0].n_membered_rings(reaction.reactant) == [ + 6 + ] or bond_rearrs[1].n_membered_rings(reaction.reactant) == [6] + + +def test_n_membered_rings(): + h2o = Molecule(atoms=[Atom("O"), Atom("H", x=-1), Atom("H", x=1)]) + bond_rearr = BondRearrangement(forming_bonds=[(1, 2)]) + + # Forming bond over H-H should give a single 3-membered ring + assert bond_rearr.n_membered_rings(h2o) == [3] + + bond_rearr = BondRearrangement(breaking_bonds=[(0, 1)]) + assert bond_rearr.n_membered_rings(h2o) == [] + + # Breaking an O-H and forming a H-H should not make any rings + bond_rearr = BondRearrangement( + breaking_bonds=[(0, 2)], forming_bonds=[(1, 2)] + ) + assert bond_rearr.n_membered_rings(h2o) == [3] + + +def test_prune_small_rings(): + # Cope rearrangement reactant + cope_r = Molecule( + atoms=[ + Atom("C", -1.58954, 1.52916, -0.43451), + Atom("C", -1.46263, 0.23506, 0.39601), + Atom("C", -0.57752, 2.62322, -0.15485), + Atom("H", -2.59004, 1.96603, -0.22830), + Atom("H", -1.55607, 1.26799, -1.51381), + Atom("C", 0.40039, 2.56394, 0.75883), + Atom("C", -0.24032, -0.62491, 0.13974), + Atom("H", -2.34641, -0.39922, 0.17008), + Atom("H", -1.50638, 0.49516, 1.47520), + Atom("C", 0.72280, -0.36227, -0.75367), + Atom("H", -0.66513, 3.53229, -0.74242), + Atom("H", 0.55469, 1.70002, 1.39347), + Atom("H", 1.07048, 3.40870, 0.88117), + Atom("H", -0.14975, -1.53366, 0.72733), + Atom("H", 0.70779, 0.51623, -1.38684), + Atom("H", 1.55578, -1.04956, -0.86026), + ] + ) + + six_mem = BondRearrangement( + forming_bonds=[(5, 9)], breaking_bonds=[(1, 0)] + ) + assert six_mem.n_membered_rings(mol=cope_r) == [6] + + four_mem = BondRearrangement( + forming_bonds=[(0, 9)], breaking_bonds=[(1, 0)] + ) + assert four_mem.n_membered_rings(cope_r) == [4] + + ade.Config.skip_small_ring_tss = False + + bond_rearrs = [six_mem, four_mem] + br.prune_small_ring_rearrs(possible_brs=bond_rearrs, mol=cope_r) + # Should not prune if Config.skip_small_ring_tss = False + assert len(bond_rearrs) == 2 + + ade.Config.skip_small_ring_tss = True + + br.prune_small_ring_rearrs(possible_brs=bond_rearrs, mol=cope_r) + # should remove the 4-membered ring + assert len(bond_rearrs) == 1 + + +def test_multiple_possibilities(): + r1 = Molecule(name="h_dot", smiles="[H]") + r2 = Molecule(name="methane", smiles="C") + p1 = Molecule(name="h2", smiles="[HH]") + p2 = Molecule(name="ch3_dot", smiles="[CH3]") + + reac = ReactantComplex(r1, r2) + + rearrs = br.get_bond_rearrangs( + reac, ProductComplex(p1, p2), name="H_subst", save=False + ) + + # All H abstractions are the same + assert len(rearrs) == 1 + + +def test_multiple_possibles2(): + # Attack on oxirane by AcO- + reaction = ade.Reaction("[O-]C(C)=O.C1CO1>>[O-]CCOC(C)=O") + + rearrs = br.get_bond_rearrangs( + reaction.reactant, reaction.product, name="oxir_attack", save=False + ) + assert len(rearrs) == 1 + + +def test_bondrearr_class(): + # Reaction H + H2 -> H2 + H + rearrang = br.BondRearrangement( + forming_bonds=[(0, 1)], breaking_bonds=[(1, 2)] + ) + + assert rearrang.n_fbonds == 1 + assert rearrang.n_bbonds == 1 + assert str(rearrang) == "0-1_1-2" + + rearrag2 = br.BondRearrangement( + forming_bonds=[(0, 1)], breaking_bonds=[(1, 2)] + ) + assert rearrag2 == rearrang + + mol = Molecule( + name="mol", + atoms=[ + Atom("H", 0.0, 0.0, 0.0), + Atom("H", 0.0, 0.0, -0.7), + Atom("H", 0.0, 0.0, 0.7), + ], + ) + mol_c = ReactantComplex(mol) + + assert set(rearrang.active_atoms) == {0, 1, 2} + active_atom_nl = rearrang.get_active_atom_neighbour_lists(mol_c, depth=1) + assert len(active_atom_nl) == 3 + assert active_atom_nl == [["H"], ["H"], ["H"]] + + # + assert rearrang.get_active_atom_neighbour_lists(mol, depth=1) == [ + ["H"], + ["H"], + ["H"], + ] + + # Cannot get neighbour list with atoms not in the complex + with pytest.raises(ValueError): + rearrang = br.BondRearrangement(forming_bonds=[(3, 4)]) + _ = rearrang.get_active_atom_neighbour_lists(mol_c, depth=1) + + +def test_get_bond_rearrangs(): + # ethane --> Ch3 + Ch3 + reac = Molecule(smiles="CC") + prod = Molecule( + atoms=[ + Atom("C", -8.3, 1.4, 0.0), + Atom("C", 12, 1.7, -0.0), + Atom("H", -8.6, 0.5, -0.5), + Atom("H", -8.6, 2.3, -0.4), + Atom("H", -8.6, 1.3, 1), + Atom("H", 12.3, 1.7, -1.0), + Atom("H", 12.4, 0.8, 0.4), + Atom("H", 12.3, 2.5, 0.5), + ] + ) + + assert br.get_bond_rearrangs( + ReactantComplex(reac), ProductComplex(prod), name="test", save=False + ) == [br.BondRearrangement(breaking_bonds=[(0, 1)])] + + # Rerunning the get function should read test_bond_rearrangs.txt, so modify + # it, swapping 0 and 1 in the breaking + # bond then reopen + with open("test_bond_rearrangs.txt", "w") as rearr_file: + print("fbond\n" "bbonds\n" "1 0\n" "end", file=rearr_file) + + rearr = br.get_bond_rearrangs( + ReactantComplex(reac), ProductComplex(prod), name="test" + )[0] + assert rearr == BondRearrangement(breaking_bonds=[(1, 0)]) + os.remove("test_bond_rearrangs.txt") + + assert ( + br.get_bond_rearrangs( + ReactantComplex(prod), + ProductComplex(reac), + name="test2", + save=False, + ) + is None + ) + + # If reactants and products are identical then the rearrangement is + # undetermined + assert ( + br.get_bond_rearrangs( + ReactantComplex(reac), + ProductComplex(reac), + name="test3", + save=False, + ) + is None + ) + + +def test_two_possibles(): + ch2ch3f = Molecule( + name="radical", charge=0, mult=2, smiles="FC[C]([H])[H]" + ) + + ch3ch2f = Molecule(name="radical", charge=0, mult=2, smiles="C[C]([H])F") + + rearrs = br.get_bond_rearrangs( + ReactantComplex(ch2ch3f), + ProductComplex(ch3ch2f), + name="H_migration", + save=False, + ) + + # There are two possibilities for H migration by they should be considered + # the same + assert len(rearrs) == 1 + + +def test_add_bond_rearrang(): + reac = Molecule(atoms=[Atom("H", 0, 0, 0), Atom("H", 0.6, 0, 0)]) + prod = Molecule(atoms=[Atom("H", 0, 0, 0), Atom("H", 10, 0, 0)]) + assert br.add_bond_rearrangment([], reac, prod, [], [(0, 1)]) == [ + br.BondRearrangement(breaking_bonds=[(0, 1)]) + ] + + +def test_generate_rearranged_graph(): + init_graph = MolecularGraph() + final_graph = MolecularGraph() + init_edges = [(0, 1), (1, 2), (2, 3), (4, 5), (5, 6)] + final_edges = [(0, 1), (2, 3), (3, 4), (4, 5), (5, 6)] + for edge in init_edges: + init_graph.add_edge(*edge) + for edge in final_edges: + final_graph.add_edge(*edge) + assert is_isomorphic( + br.generate_rearranged_graph(init_graph, [(3, 4)], [(1, 2)]), + final_graph, + ) + + +def test_2b(): + reac = Molecule( + atoms=[Atom("H", 0, 0, 0), Atom("H", 0.6, 0, 0), Atom("H", 1.2, 0, 0)] + ) + make_graph(reac, allow_invalid_valancies=True) + prod = Molecule( + atoms=[Atom("H", 0, 0, 0), Atom("H", 10, 0, 0), Atom("H", 20, 0, 0)] + ) + + # Reactants to products must break two bonds + assert ( + len( + br.get_bond_rearrangs( + ReactantComplex(reac), + ProductComplex(prod), + name="2b_test", + save=False, + ) + ) + == 1 + ) + + assert br.get_fbonds_bbonds_2b( + reac, prod, [], [[(0, 1), (1, 2)]], [], [], [(0, 2)], [] + ) == [br.BondRearrangement(breaking_bonds=[(0, 1), (1, 2)])] + + +def test_3b(): + reac = Molecule( + atoms=[ + Atom("H", 0, 0, 0), + Atom("H", 0.6, 0, 0), + Atom("H", 1.2, 0, 0), + Atom("H", 1.8, 0, 0), + ] + ) + make_graph(reac, allow_invalid_valancies=True) + prod = Molecule( + atoms=[ + Atom("H", 0, 0, 0), + Atom("H", 10, 0, 0), + Atom("H", 20, 0, 0), + Atom("H", 30, 0, 0), + ] + ) + + # Reactants to products must break three bonds but this is not yet supported in any form + assert ( + br.get_bond_rearrangs( + ReactantComplex(reac), + ProductComplex(prod), + name="3b_test", + save=False, + ) + is None + ) + + +def test_1b1f(): + reac = Molecule( + atoms=[Atom("C", 0, 0, 0), Atom("H", 0.6, 0, 0), Atom("H", 10, 0, 0)] + ) + prod = Molecule( + atoms=[Atom("C", 0, 0, 0), Atom("H", 10, 0, 0), Atom("H", 10.6, 0, 0)] + ) + assert br.get_fbonds_bbonds_1b1f( + reac, prod, [], [[(0, 1)]], [[(1, 2)]], [], [], [] + ) == [ + br.BondRearrangement(forming_bonds=[(1, 2)], breaking_bonds=[(0, 1)]) + ] + + reac = Molecule( + atoms=[Atom("H", 0, 0, 0), Atom("H", 0.6, 0, 0), Atom("H", 10, 0, 0)] + ) + prod = Molecule( + atoms=[Atom("H", 0, 0, 0), Atom("H", 10, 0, 0), Atom("H", 10.6, 0, 0)] + ) + assert br.get_fbonds_bbonds_1b1f( + reac, prod, [], [], [], [[[(0, 1)], [(1, 2)]]], [], [] + ) == [ + br.BondRearrangement(forming_bonds=[(1, 2)], breaking_bonds=[(0, 1)]) + ] + + +def test_2b1f(): + reac = Molecule( + atoms=[Atom("H", 0, 0, 0), Atom("C", 0.6, 0, 0), Atom("O", 1.2, 0, 0)] + ) + make_graph(reac, allow_invalid_valancies=True) + prod = Molecule( + atoms=[Atom("H", 0, 0, 0), Atom("C", 10, 0, 0), Atom("O", 0.6, 0, 0)] + ) + assert br.get_fbonds_bbonds_2b1f( + reac, prod, [], [[(0, 1)], [(1, 2)]], [[(0, 2)]], [], [], [] + ) == [ + br.BondRearrangement( + forming_bonds=[(0, 2)], breaking_bonds=[(0, 1), (1, 2)] + ) + ] + + reac = Molecule( + atoms=[Atom("H", 0, 0, 0), Atom("C", 0.6, 0, 0), Atom("H", 1.2, 0, 0)] + ) + make_graph(reac, allow_invalid_valancies=True) + prod = Molecule( + atoms=[Atom("H", 0, 0, 0), Atom("C", 10, 0, 0), Atom("H", 0.6, 0, 0)] + ) + assert br.get_fbonds_bbonds_2b1f( + reac, prod, [], [[(0, 1), (1, 2)]], [[(0, 2)]], [], [], [] + ) == [ + br.BondRearrangement( + forming_bonds=[(0, 2)], breaking_bonds=[(0, 1), (1, 2)] + ) + ] + + reac = Molecule( + atoms=[Atom("H", 0, 0, 0), Atom("H", 0.6, 0, 0), Atom("H", 1.2, 0, 0)] + ) + make_graph(reac, allow_invalid_valancies=True) + prod = Molecule( + atoms=[Atom("H", 0, 0, 0), Atom("H", 0.6, 0, 0), Atom("H", 10, 0, 0)] + ) + assert br.get_fbonds_bbonds_2b1f( + reac, prod, [], [[(0, 1), (1, 2)]], [], [], [(0, 2)], [] + ) == [ + br.BondRearrangement( + forming_bonds=[(0, 2)], breaking_bonds=[(0, 1), (1, 2)] + ) + ] + + +def test_2b2f(): + reac = Molecule( + atoms=[ + Atom("H", 0, 0, 0), + Atom("C", 0.6, 0, 0), + Atom("N", 10, 0, 0), + Atom("O", 10.6, 0, 0), + ] + ) + prod = Molecule( + atoms=[ + Atom("H", 0, 0, 0), + Atom("C", 10, 0, 0), + Atom("N", 0.6, 0, 0), + Atom("O", 10.6, 0, 0), + ] + ) + + assert br.get_fbonds_bbonds_2b2f( + reac, prod, [], [[(0, 1)], [(2, 3)]], [[(0, 2)], [(1, 3)]], [], [], [] + ) == [ + br.BondRearrangement( + forming_bonds=[(0, 2), (1, 3)], breaking_bonds=[(0, 1), (2, 3)] + ) + ] + + reac = Molecule( + atoms=[ + Atom("H", 0, 0, 0), + Atom("C", 0.6, 0, 0), + Atom("H", 10, 0, 0), + Atom("N", 10.6, 0, 0), + Atom("O", 20, 0, 0), + ] + ) + prod = Molecule( + atoms=[ + Atom("H", 0, 0, 0), + Atom("C", 10, 0, 0), + Atom("H", 1.2, 0, 0), + Atom("N", 20, 0, 0), + Atom("O", 0.6, 0, 0), + ] + ) + + assert br.get_fbonds_bbonds_2b2f( + reac, prod, [], [[(0, 1)], [(2, 3)]], [[(0, 4), (2, 4)]], [], [], [] + ) == [ + br.BondRearrangement( + forming_bonds=[(0, 4), (2, 4)], breaking_bonds=[(0, 1), (2, 3)] + ) + ] + + reac = Molecule( + atoms=[ + Atom("H", 0, 0, 0), + Atom("C", 0.6, 0, 0), + Atom("H", 1.2, 0, 0), + Atom("O", 10, 0, 0), + ] + ) + prod = Molecule( + atoms=[ + Atom("H", 0, 0, 0), + Atom("C", 10, 0, 0), + Atom("H", 11.2, 0, 0), + Atom("O", 10.6, 0, 0), + ] + ) + assert br.get_fbonds_bbonds_2b2f( + reac, + prod, + [], + [[(0, 1), (1, 2)]], + [[(0, 3), (2, 3)], [(1, 3)]], + [], + [], + [], + ) == [ + br.BondRearrangement( + forming_bonds=[(0, 3), (1, 3)], breaking_bonds=[(0, 1), (1, 2)] + ), + br.BondRearrangement( + forming_bonds=[(1, 3), (2, 3)], breaking_bonds=[(0, 1), (1, 2)] + ), + ] + + reac = Molecule( + atoms=[ + Atom("H", 0, 0, 0), + Atom("C", 0.6, 0, 0), + Atom("H", 1.2, 0, 0), + Atom("O", 10, 0, 0), + ] + ) + prod = Molecule( + atoms=[ + Atom("H", 0, 0, 0), + Atom("C", 10, 0, 0), + Atom("H", 1.2, 0, 0), + Atom("O", 0.6, 0, 0), + ] + ) + assert br.get_fbonds_bbonds_2b2f( + reac, prod, [], [[(0, 1), (1, 2)]], [[(0, 3), (2, 3)]], [], [], [] + ) == [ + br.BondRearrangement( + forming_bonds=[(0, 3), (2, 3)], breaking_bonds=[(0, 1), (1, 2)] + ) + ] + + reac = Molecule( + atoms=[ + Atom("H", 0, 0, 0), + Atom("C", 0.6, 0, 0), + Atom("N", 1.2, 0, 0), + Atom("C", 10, 0, 0), + ] + ) + prod = Molecule( + atoms=[ + Atom("H", 0, 0, 0), + Atom("C", 10, 0, 0), + Atom("N", 1.2, 0, 0), + Atom("C", 0.6, 0, 0), + ] + ) + assert br.get_fbonds_bbonds_2b2f( + reac, + prod, + [], + [], + [], + [[[(0, 1)], [(0, 3)]], [[(1, 2)], [(2, 3)]]], + [], + [], + ) == [ + br.BondRearrangement( + forming_bonds=[(0, 3), (2, 3)], breaking_bonds=[(0, 1), (1, 2)] + ) + ] + + +def test_br_from_file(): + path = "/a/path/that/doesnt/exist" + assert br.get_bond_rearrangs_from_file(filename=path) is None + + with open("tmp.txt", "w") as br_file: + print("fbonds\n" "0 1\n" "end", file=br_file) + + saved_brs = br.get_bond_rearrangs_from_file(filename="tmp.txt") + assert len(saved_brs) == 1 + + saved_br = saved_brs[0] + assert saved_br.n_fbonds == 1 + assert saved_br.n_bbonds == 0 + + with open("tmp.txt", "w") as br_file: + print( + "fbonds\n" "1 12\n" "bbonds\n" "6 12\n" "7 8\n" "endn\n", + file=br_file, + ) + + saved_brs = br.get_bond_rearrangs_from_file(filename="tmp.txt") + assert len(saved_brs) == 1 + + saved_br = saved_brs[0] + assert saved_br.n_fbonds == 1 + assert saved_br.n_bbonds == 2 + + os.remove("tmp.txt") + + +@work_in_tmp_dir() +def test_2b2f_single_bond_type(): + """ + Test that the bond rearrangement can be found where only OO bonds break + and only OH bonds form. Not a very realistic reaction + """ + + reac = Molecule( + atoms=[ + Atom("O", 0.0, 0.0, 0.0), + Atom("O", 1.5, 0.0, 0.0), + Atom("O", 0.0, 1.5, 0.0), + Atom("O", 1.5, 1.5, 0.0), + Atom("H", 9.0, 0.0, 0.0), + Atom("H", 9.0, 2.0, 0.0), + ] + ) + + prod = Molecule( + atoms=[ + Atom("O", -9.0, 0.0, 0.0), + Atom("O", 8.0, 0.0, 0.0), + Atom("O", -9.0, 1.0, 0.0), + Atom("O", 8.0, 1.0, 0.0), + Atom("H", 9.0, 0.0, 0.0), + Atom("H", 9.0, 1.0, 0.0), + ] + ) + + brs = get_bond_rearrangs(reac, prod, "test") + assert brs is not None and len(brs) == 1 diff --git a/autodE/source/tests/test_bracket/__init__.py b/autodE/source/tests/test_bracket/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/autodE/source/tests/test_bracket/data/geometries.zip b/autodE/source/tests/test_bracket/data/geometries.zip new file mode 100644 index 0000000000000000000000000000000000000000..fa13b3514db95efca4e0a7e460f53845ccfe21b9 --- /dev/null +++ b/autodE/source/tests/test_bracket/data/geometries.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f28d358df8848992420b3ba2263fd8727ead297fa3ff479bc42d3324e0b0d361 +size 185262 diff --git a/autodE/source/tests/test_bracket/test_dhs.py b/autodE/source/tests/test_bracket/test_dhs.py new file mode 100644 index 0000000000000000000000000000000000000000..21091e8a55739d8298b7ebc76f2a6985c13d2b64 --- /dev/null +++ b/autodE/source/tests/test_bracket/test_dhs.py @@ -0,0 +1,358 @@ +import os +import numpy as np +import pytest +from scipy.optimize import minimize +from autode import Molecule +from autode.methods import XTB +from autode.values import PotentialEnergy +from autode.utils import work_in_tmp_dir +from autode.geom import calc_rmsd +from autode.bracket.dhs import ( + DHS, + DHSGS, + DistanceConstrainedOptimiser, + DHSImagePair, + ImageSide, + OptimiserStepError, +) +from autode.opt.coordinates import CartesianCoordinates +from autode import Config +from ..testutils import requires_working_xtb_install, work_in_zipped_dir + +here = os.path.dirname(os.path.abspath(__file__)) +datazip = os.path.join(here, "data", "geometries.zip") + + +@requires_working_xtb_install +@work_in_zipped_dir(datazip) +def test_distance_constrained_optimiser(): + reactant = Molecule("da_reactant.xyz") + product = Molecule("da_product.xyz") + rct_coords = CartesianCoordinates(reactant.coordinates) + prod_coords = CartesianCoordinates(product.coordinates) + + # displace product coordinates towards reactant + dist_vec = prod_coords - rct_coords + prod_coords = prod_coords - 0.1 * dist_vec + distance = np.linalg.norm(prod_coords - rct_coords) + product.coordinates = prod_coords + + opt = DistanceConstrainedOptimiser( + pivot_point=rct_coords, + maxiter=1, # just one step + init_trust=0.2, + conv_tol="loose", + ) + opt.run(product, method=XTB()) + assert not opt.converged + prod_coords_new = opt.final_coordinates + + # distance should not change + new_distance = np.linalg.norm(prod_coords_new - rct_coords) + assert np.isclose(new_distance, distance) + # linear interpolation is skipped on first step + # should be less than or equal to trust radius + step_size = np.linalg.norm(prod_coords_new - prod_coords) + fp_err = 0.000001 + assert step_size <= 0.2 + fp_err * 0.2 # for floating point error + + opt = DistanceConstrainedOptimiser( + pivot_point=rct_coords, maxiter=50, conv_tol="loose" + ) + opt.run(product, method=XTB()) + assert opt.converged + prod_coords_new = opt.final_coordinates + new_distance = np.linalg.norm(prod_coords_new - rct_coords) + assert np.isclose(new_distance, distance) + + +@work_in_zipped_dir(datazip) +def test_dist_constr_optimiser_sd_fallback(): + coords1 = CartesianCoordinates(np.loadtxt("conopt_last.txt")) + coords1.update_g_from_cart_g(np.loadtxt("conopt_last_g.txt")) + coords1.update_h_from_cart_h(np.loadtxt("conopt_last_h.txt")) + pivot = CartesianCoordinates(np.loadtxt("conopt_pivot.txt")) + + # lagrangian step may fail at certain points + opt = DistanceConstrainedOptimiser( + pivot_point=pivot, maxiter=2, init_trust=0.2, conv_tol="loose" + ) + opt._target_dist = 2.6869833732268 + opt._history.open("test_trj") + opt._history.add(coords1) + with pytest.raises(OptimiserStepError): + opt._get_lagrangian_step(coords1, coords1.g) + # however, steepest descent step should work + sd_step = opt._get_sd_step(coords1, coords1.g) + opt._step() + assert np.allclose(opt._coords, coords1 + sd_step) + + +@work_in_tmp_dir() +def test_dist_constr_optimiser_energy_rising(): + coords1 = CartesianCoordinates(np.arange(6, dtype=float)) + coords1.e = PotentialEnergy(0.01, "Ha") + step = np.random.random(6) + coords2 = coords1 + step + coords2.e = PotentialEnergy(0.02, "Ha") + assert (coords2.e - coords1.e) > PotentialEnergy(5, "kcalmol") + opt = DistanceConstrainedOptimiser( + pivot_point=coords1, maxiter=2, conv_tol="loose" + ) + opt._history.open("test_trj") + opt._history.add(coords1) + opt._history.add(coords2) + opt._step() + assert np.allclose(opt._coords, coords1 + step * 0.5) + + +def test_dhs_image_pair(): + mol1 = Molecule(smiles="CCO") + mol2 = mol1.new_species() + imgpair = DHSImagePair(mol1, mol2) + + coords = imgpair.left_coords + 0.1 + + # check the functions that get one side + with pytest.raises(ValueError): + imgpair.put_coord_by_side(coords, 2) + + imgpair.left_coords = coords + with pytest.raises(ValueError): + step = imgpair.get_last_step_by_side(2) + + # with "left" it should not cause any issues + step = imgpair.get_last_step_by_side(ImageSide.left) + assert isinstance(step, np.ndarray) + + with pytest.raises(ValueError): + imgpair.get_coord_by_side(1) + + +def test_dhs_image_pair_ts_guess(caplog): + mol1 = Molecule(smiles="CCO") + imgpair = DHSImagePair(mol1, mol1.copy()) + + imgpair.left_coords.e = PotentialEnergy(-0.144, "Ha") + + with caplog.at_level("ERROR"): + peak = imgpair.ts_guess + assert "Energy values are missing in the trajectory" in caplog.text + + imgpair.right_coords.e = PotentialEnergy(-0.145, "Ha") + imgpair.left_coords.g = np.ones_like(imgpair.left_coords) # spoof gradient + peak = imgpair.ts_guess + assert peak is not None + + assert np.allclose(peak.coordinates.flatten(), imgpair.left_coords) + assert np.isclose(peak.energy, -0.144) + assert np.allclose( + np.asarray(peak.gradient.flatten()), np.asarray(imgpair.left_coords.g) + ) + + +@requires_working_xtb_install +@work_in_zipped_dir(datazip) +def test_dhs_single_step(): + step_size = 0.2 + reactant = Molecule("da_reactant.xyz") + product = Molecule("da_product.xyz") + + dhs = DHS( + initial_species=reactant, + final_species=product, + maxiter=200, + large_step=0.2, + switch_thresh=1.5, + dist_tol=1.0, + ) + + dhs.imgpair.set_method_and_n_cores(method=XTB(), n_cores=1) + dhs._method = XTB() + dhs._initialise_run() + + imgpair = dhs.imgpair + assert imgpair.left_coords.e is not None + assert imgpair.right_coords.e is not None + old_dist = imgpair.dist + assert imgpair.left_coords.e > imgpair.right_coords.e + + assert dhs.imgpair.dist > 1.5 + # take a single step + dhs._step() + # step should be on lower energy image + assert len(imgpair._left_history) == 1 + assert len(imgpair._right_history) == 2 + new_dist = imgpair.dist + # image should move exactly by large step + assert np.isclose(old_dist - new_dist, step_size) + + +@requires_working_xtb_install +@work_in_zipped_dir(datazip) +def test_dhs_gs_single_step(caplog): + step_size = 0.2 + reactant = Molecule("da_reactant.xyz") + product = Molecule("da_product.xyz") + + # DHS-GS from end point of DHS (first step is always 100% DHS) + dhs_gs = DHSGS( + initial_species=reactant, + final_species=product, + maxiter=200, + large_step=step_size, + switch_thresh=1.5, + dist_tol=1.0, + gs_mix=0.5, + ) + + dhs_gs.imgpair.set_method_and_n_cores(method=XTB(), n_cores=1) + dhs_gs._method = XTB() + dhs_gs._initialise_run() + assert dhs_gs.imgpair.dist > 1.5 + # take one step + with caplog.at_level("INFO"): + dhs_gs._step() + dhs_gs._log_convergence() + assert "DHS-GS" in caplog.text + right_pred = dhs_gs._get_dhs_step(ImageSide.right) # 50% DHS + 50% GS + + imgpair = dhs_gs.imgpair + assert imgpair.left_coords.e > imgpair.right_coords.e + assert len(imgpair._left_history) == 1 + assert len(imgpair._right_history) == 2 + + hybrid_step = right_pred - imgpair.right_coords + dhs_step = imgpair.dist_vec + dhs_step = dhs_step / np.linalg.norm(dhs_step) * step_size + gs_step = imgpair._right_history[-1] - imgpair._right_history[-2] + gs_step = gs_step / np.linalg.norm(gs_step) * step_size + + assert np.allclose(hybrid_step, 0.5 * dhs_step + 0.5 * gs_step) + + +@requires_working_xtb_install +@work_in_zipped_dir(datazip) +def test_dhs_diels_alder(): + set_dist_tol = 1.0 # angstrom + + # Use almost converged images for quick calculation + reactant = Molecule("da_rct_image.xyz") + product = Molecule("da_prod_image.xyz") + # TS optimized with ORCA using xTB method + true_ts = Molecule("da_ts_orca_xtb.xyz") + + dhs = DHS( + initial_species=reactant, + final_species=product, + small_step=0.2, + maxiter=100, + dist_tol=set_dist_tol, + ) + + dhs.calculate(method=XTB(), n_cores=Config.n_cores) + assert dhs.converged + peak = dhs.ts_guess + + rmsd = calc_rmsd(peak.coordinates, true_ts.coordinates) + # Euclidean distance = rmsd * sqrt(n_atoms) + distance = rmsd * np.sqrt(peak.n_atoms) + + # the true TS must be within the last two DHS images, therefore + # the distance must be less than the distance tolerance + # (assuming curvature of PES near TS not being too high) + assert distance < set_dist_tol + + # trajectories and default energy plot should be in "dhs" folder + assert os.path.isfile("dhs/initial_species_DHS.trj.xyz") + assert os.path.isfile("dhs/final_species_DHS.trj.xyz") + assert os.path.isfile("dhs/total_trajectory_DHS.trj.xyz") + assert os.path.isfile("dhs/DHS_path_energy_plot.pdf") + + # now run CI-NEB from end points + dhs.run_cineb() + assert dhs.imgpair._cineb_coords is not None + assert dhs.imgpair._cineb_coords.e is not None + peak = dhs.ts_guess + + rmsd = calc_rmsd(peak.coordinates, true_ts.coordinates) + # Euclidean distance = rmsd * sqrt(n_atoms) + new_distance = rmsd * np.sqrt(peak.n_atoms) + + # Now distance should be closer + assert new_distance < distance + assert new_distance < 0.6 * set_dist_tol + + # test graph plotting again, with all available options + dhs.plot_energies("DHS_relative_dist.pdf", distance_metric="relative") + dhs.plot_energies("DHS_by_index.pdf", distance_metric="index") + dhs.plot_energies("DHS_dist_from_start.pdf", distance_metric="from_start") + for filename in [ + "DHS_relative_dist.pdf", + "DHS_by_index.pdf", + "DHS_dist_from_start.pdf", + ]: + assert os.path.isfile(filename) + + +@requires_working_xtb_install +@work_in_zipped_dir(datazip) +def test_dhs_jumping_over_barrier(caplog): + # Use almost converged images for quick calculation + reactant = Molecule("da_rct_image.xyz") + product = Molecule("da_prod_image.xyz") + + # run DHS with large step sizes, which will make one side jump + dhs = DHS( + initial_species=reactant, + final_species=product, + maxiter=50, + large_step=0.5, + small_step=0.5, + dist_tol=0.3, # smaller dist_tol also to make one side jump + conv_tol="loose", + barrier_check=True, + cineb_at_conv=True, + ) + with caplog.at_level("WARNING"): + dhs.calculate(method=XTB(), n_cores=Config.n_cores) + + assert "One image has probably jumped over the barrier" in caplog.text + assert not dhs.converged + # CI-NEB should not be run if one image has jumped over + assert "has not converged properly or one side has jumped" in caplog.text + assert dhs.imgpair._cineb_coords is None + + +@requires_working_xtb_install +@work_in_zipped_dir(datazip) +def test_dhs_stops_if_microiter_exceeded(caplog): + reactant = Molecule("da_rct_image.xyz") + product = Molecule("da_prod_image.xyz") + + # run DHS with low maxiter + dhs = DHS( + initial_species=reactant, + final_species=product, + maxiter=5, + large_step=0.2, + small_step=0.1, + dist_tol=1.0, + conv_tol="loose", + barrier_check=True, + ) + with caplog.at_level("WARNING"): + dhs.calculate(method=XTB(), n_cores=1) + + assert not dhs.converged + text = "Reached the maximum number of micro-iterations" + assert text in caplog.text + + +def test_method_names(): + # check all method names are properly written + mol1 = Molecule(smiles="CCO") + dhs = DHS(mol1, mol1.copy()) + assert dhs._name == "DHS" + dhs_gs = DHSGS(mol1, mol1.copy()) + assert dhs_gs._name == "DHSGS" diff --git a/autodE/source/tests/test_bracket/test_ieip.py b/autodE/source/tests/test_bracket/test_ieip.py new file mode 100644 index 0000000000000000000000000000000000000000..aa64d1337b9a03fff63cfaa18fe78e00fd2b0472 --- /dev/null +++ b/autodE/source/tests/test_bracket/test_ieip.py @@ -0,0 +1,139 @@ +import os +import numpy as np +import pytest + +from autode.species import Molecule +from autode.methods import XTB +from autode.bracket.ieip import IEIP, ElasticImagePair, IEIPMicroIters +from autode.bracket.ieip import _calculate_low_sp_energy_for_species +from autode.geom import calc_rmsd +from ..testutils import requires_working_xtb_install, work_in_zipped_dir + +here = os.path.dirname(os.path.abspath(__file__)) +datazip = os.path.join(here, "data", "geometries.zip") + + +@requires_working_xtb_install +@work_in_zipped_dir(datazip) +def test_ieip_initialisation(): + rct = Molecule("da_reactant.xyz") + prod = Molecule("da_product.xyz") + ieip = IEIP(rct, prod) + dist_orig = ieip.imgpair.dist + ieip.imgpair.set_method_and_n_cores(method=XTB(), n_cores=1) + ieip._initialise_run() + # redistribution means one pair of coords should be added + assert len(ieip.imgpair._left_history) == 2 + assert len(ieip.imgpair._right_history) == 2 + # the distance should be lower + dist_curr = ieip.imgpair.dist + assert dist_curr < dist_orig + # the current geometries should be reasonable + assert ieip.imgpair._left_image.has_reasonable_coordinates + assert ieip.imgpair._right_image.has_reasonable_coordinates + # the hessian and gradients should be set + assert ieip.imgpair.left_coords.g is not None + assert ieip.imgpair.right_coords.g is not None + assert ieip.imgpair.left_coords.h is not None + assert ieip.imgpair.right_coords.h is not None + # the target distance and rms g should be set + assert ieip._target_dist is not None + assert ieip._target_rms_g is not None + # there should be no ts guess without at least one iter + assert ieip.ts_guess is None + + +@requires_working_xtb_install +def test_low_sp_func(): + mol = Molecule(smiles="CCO") + en = _calculate_low_sp_energy_for_species(mol, method=XTB(), n_cores=1) + assert en is not None + assert hasattr(en, "units") + + +@requires_working_xtb_install +@work_in_zipped_dir(datazip) +def test_ieip_microiters(): + micro_step_size = 1e-4 + # use almost converged images for quick calc + rct = Molecule("da_rct_image.xyz") + prod = Molecule("da_prod_image.xyz") + imgpair = ElasticImagePair(rct, prod) + imgpair.set_method_and_n_cores(method=XTB(), n_cores=1) + imgpair.update_both_img_engrad() + # load hessian from txt to save time + imgpair.left_coords.h = np.loadtxt("da_rct_image_hess.txt") + imgpair.right_coords.h = np.loadtxt("da_prod_image_hess.txt") + # micro iterations + micro_imgpair = IEIPMicroIters( + imgpair.left_coords, + imgpair.right_coords, + micro_step_size=micro_step_size, + target_dist=imgpair.dist * 0.9, + ) + micro_imgpair.update_both_img_engrad() + micro_imgpair.take_micro_step() + # the step size should be equal to the provided step size + assert np.isclose(micro_imgpair.max_displacement, micro_step_size) + # need to update gradients from the Taylor surfaces before step + with pytest.raises(AssertionError): + micro_imgpair.take_micro_step() + for _ in range(3): + micro_imgpair.update_both_img_engrad() + micro_imgpair.take_micro_step() + assert micro_imgpair.n_micro_iters == 4 + + +@requires_working_xtb_install +@work_in_zipped_dir(datazip) +def test_ieip_diels_alder(): + set_dist_tol = 1.0 # Angstrom + set_step_size = 0.2 # Angstrom + # Use almost converged images for quick calculation + reactant = Molecule("da_rct_image.xyz") + product = Molecule("da_prod_image.xyz") + # TS optimized with ORCA using xTB method + true_ts = Molecule("da_ts_orca_xtb.xyz") + ieip = IEIP( + initial_species=reactant, + final_species=product, + use_ll_neb_interp=False, + interp_fraction=3 / 4, + dist_tol=set_dist_tol, + maxiter=5, + max_macro_step=set_step_size, + ) + dist_curr = ieip.imgpair.dist + ieip.imgpair.set_method_and_n_cores(XTB(), n_cores=1) + ieip.imgpair.update_both_img_engrad() + prev_de = abs(ieip.imgpair.left_coords.e - ieip.imgpair.right_coords.e) + prev_dist = ieip.imgpair.dist + # load hessian from txt to save time + ieip.imgpair.left_coords.h = np.loadtxt("da_rct_image_hess.txt") + ieip.imgpair.right_coords.h = np.loadtxt("da_prod_image_hess.txt") + ieip._target_dist = dist_curr + prev_rms_g = min(max(dist_curr / set_dist_tol, 1), 2) * ieip._gtol + ieip._target_rms_g = prev_rms_g + + ieip._step() + # step sizes should be within set size + assert ieip.imgpair.last_left_step_size <= set_step_size + assert ieip.imgpair.last_right_step_size <= set_step_size + # energy should be more equalised after a step + curr_de = abs(ieip.imgpair.left_coords.e - ieip.imgpair.right_coords.e) + assert curr_de < prev_de + + # now take only 5 steps + while not ieip.converged: + ieip._step() + if ieip._exceeded_maximum_iteration: + break + assert ieip._macro_iter == 5 + assert len(ieip.imgpair._left_history) == 7 + # check that distance is going down and the rms g is also tightened + assert ieip.imgpair.dist <= prev_dist + assert ieip._target_rms_g < prev_rms_g + # interpolated guess should be reasonable even if not converged + peak = ieip.ts_guess + rmsd = calc_rmsd(peak.coordinates, true_ts.coordinates) + assert rmsd < 0.2 diff --git a/autodE/source/tests/test_bracket/test_imagepair.py b/autodE/source/tests/test_bracket/test_imagepair.py new file mode 100644 index 0000000000000000000000000000000000000000..1f9bcdb5d0a6ea5c6037a2682afbc0ecc0287c31 --- /dev/null +++ b/autodE/source/tests/test_bracket/test_imagepair.py @@ -0,0 +1,302 @@ +import os +import numpy as np +import pytest + +from autode import Molecule, Atom +from autode.geom import calc_rmsd +from autode.opt.coordinates import CartesianCoordinates +from autode.methods import XTB +from autode.values import Energy +from autode.utils import work_in_tmp_dir +from autode.bracket.imagepair import ( + EuclideanImagePair, + _calculate_engrad_for_species, + _calculate_hessian_for_species, +) +from ..testutils import work_in_zipped_dir, requires_working_xtb_install + +here = os.path.dirname(os.path.abspath(__file__)) +datazip = os.path.join(here, "data", "geometries.zip") + + +class NullImagePair(EuclideanImagePair): + """Used for testing""" + + @property + def ts_guess(self): + return None + + +def test_imagpair_coordinates(): + mol = Molecule(smiles="CCO") + imgpair = NullImagePair(mol, mol.copy()) + + # error on setting wrong type of coordinates, even if + # it's an array of right shape + coord_array = np.array(mol.coordinates.flatten()) + with pytest.raises(TypeError): + imgpair.left_coords = coord_array + with pytest.raises(TypeError): + imgpair.right_coords = coord_array + + coords = CartesianCoordinates(mol.coordinates) + coords += 0.1 + # no error if Cartesian coordinates + imgpair.left_coords = coords + coords = CartesianCoordinates(np.arange(mol.n_atoms + 1)) + num = mol.n_atoms * 3 + with pytest.raises(ValueError, match=f"Must have {num} entries"): + imgpair.left_coords = coords + with pytest.raises(ValueError, match=f"Must have {num} entries"): + imgpair.right_coords = coords + + +def test_imagepair_method_typing(): + mol = Molecule(smiles="CCO") + imgpair = NullImagePair(mol, mol.copy()) + + with pytest.raises(TypeError, match="method needs to be of type"): + imgpair.set_method_and_n_cores(method=5, n_cores=2) + + with pytest.raises(TypeError, match="hessian method needs to be of type"): + imgpair.set_method_and_n_cores(method=XTB(), n_cores=2, hess_method=5) + + +@work_in_zipped_dir(datazip) +def test_imgpair_alignment(): + # with same molecule, alignment should produce same coordinates + mol1 = Molecule("da_reactant.xyz") + mol2 = Molecule("da_reactant_rotated.xyz") + imgpair = NullImagePair(mol1, mol2) + + # alignment happens on init + new_mol1, new_mol2 = imgpair._left_image, imgpair._right_image + # left image should have been rotated to align perfectly + assert np.allclose(new_mol1.coordinates, new_mol2.coordinates, atol=1.0e-5) + # right image should be translated only, i.e. all difference same + diff = mol2.coordinates - new_mol2.coordinates + assert np.isclose(diff, diff[0]).all() + # now check a random bond distance + bond_orig = mol1.distance(0, 2) + bond_new = new_mol1.distance(0, 2) + assert abs(bond_new - bond_orig) < 0.001 + + +@work_in_zipped_dir(datazip) +def test_imgpair_sanity_check(): + mol1 = Molecule("da_reactant.xyz") + mol2 = Molecule("da_reactant_rotated.xyz") + mol3 = Molecule(smiles="CCCO") + mol4 = Molecule("da_reactant_shuffled.xyz") + + # different mol would raise Error + with pytest.raises(ValueError, match="same number of atoms"): + _ = NullImagePair(mol1, mol3) + + # different charge would raise Error + mol1.charge = -2 + with pytest.raises(ValueError, match="Charge/multiplicity/solvent"): + _ = NullImagePair(mol1, mol2) + mol1.charge = 0 + + # different multiplicity would also raise Error + mol1.mult = 3 + with pytest.raises(ValueError, match="Charge/multiplicity/solvent"): + _ = NullImagePair(mol1, mol2) + mol1.mult = 1 + + # different solvents would raise + mol1.solvent = "water" + with pytest.raises(ValueError, match="Charge/multiplicity/solvent"): + _ = NullImagePair(mol1, mol2) + mol1.solvent = None + + # different atom order should also raise Error + with pytest.raises(ValueError, match="order of atoms"): + _ = NullImagePair(mol1, mol4) + + +@work_in_zipped_dir(datazip) +def test_imgpair_distance(): + mol1 = Molecule("da_reactant.xyz") + mol2 = Molecule("da_product.xyz") + imgpair = NullImagePair(mol1, mol2) + rmsd = calc_rmsd(mol1.coordinates, mol2.coordinates) + dist = rmsd * np.sqrt(mol1.n_atoms * 3) + assert np.isclose(dist, imgpair.dist, rtol=1e-8) + + +@work_in_tmp_dir() +def test_energy_plotting_and_trajectory_ignored_if_less_than_three_points(): + mol1 = Molecule(smiles="CCO") + mol2 = Molecule(smiles="CCO") + imgpair = NullImagePair(mol1, mol2) + + # file should not be written with only two points + imgpair.plot_energies(filename="test.pdf", distance_metric="relative") + assert not os.path.isfile("test.pdf") + + imgpair.print_geometries("init.xyz", "fin.xyz", "total.xyz") + assert not os.path.isfile("init.xyz") + assert not os.path.isfile("fin.xyz") + assert not os.path.isfile("total.xyz") + + +@work_in_tmp_dir() +def test_imgpair_energy_plotting(caplog): + mol1 = Molecule(smiles="CCO") + mol2 = Molecule(smiles="CCO") + + imgpair = NullImagePair(mol1, mol2) + imgpair.left_coords.e = Energy(-3.14) + imgpair.right_coords.e = Energy(-2.87) + # spoof new coordinates + imgpair.left_coords = imgpair.left_coords * 0.99 + imgpair.right_coords = imgpair.right_coords * 0.99 + imgpair.left_coords.e = Energy(-1.99) + imgpair.right_coords.e = Energy(-2.15) + # if CINEB run at the end + imgpair._cineb_coords = imgpair.left_coords * 0.99 + imgpair._cineb_coords.e = Energy(-0.99) + + # test all distance_metrics + imgpair.plot_energies(filename="test0.pdf", distance_metric="relative") + assert os.path.isfile("test0.pdf") + imgpair.plot_energies(filename="test1.pdf", distance_metric="from_start") + assert os.path.isfile("test1.pdf") + imgpair.plot_energies(filename="test2.pdf", distance_metric="index") + assert os.path.isfile("test2.pdf") + + # distance metric should be one of the three options + with pytest.raises(KeyError): + imgpair.plot_energies(filename="test.pdf", distance_metric="abc") + + # if any energy is missing, also no plotting should be done + imgpair.left_coords.e = None + with caplog.at_level("ERROR"): + imgpair.plot_energies( + filename="test_noE.pdf", distance_metric="relative" + ) + assert not os.path.isfile("test-noE.pdf") + assert "do not have associated energies" in caplog.text + + +@work_in_tmp_dir() +def test_imgpair_trajectory_plotting(): + mol1 = Molecule(smiles="CCO") + mol2 = Molecule(smiles="CCO") + + imgpair = NullImagePair(mol1, mol2) + imgpair.left_coords = imgpair.left_coords * 0.99 + imgpair.right_coords = imgpair.right_coords * 0.99 + imgpair._cineb_coords = imgpair.left_coords * 0.99 + + imgpair.print_geometries("init.xyz", "fin.xyz", "total.xyz") + assert os.path.isfile("init.xyz") + assert os.path.isfile("fin.xyz") + assert os.path.isfile("total.xyz") + + +@requires_working_xtb_install +@work_in_zipped_dir(datazip) +def test_imgpair_calc_engrad(): + mol1 = Molecule("da_reactant.xyz") + mol2 = Molecule("da_product.xyz") + + imgpair = NullImagePair(left_image=mol1, right_image=mol2) + # without setting method, assert will be set off + with pytest.raises(AssertionError): + imgpair.update_both_img_engrad() + + imgpair.set_method_and_n_cores(method=XTB(), n_cores=1) + imgpair.update_both_img_engrad() + # energy should be updated + assert imgpair.left_coords.e is not None + assert imgpair.right_coords.e is not None + # units should be forced to Hartree + assert str(imgpair.left_coords.e.units) == "Unit(Ha)" + assert str(imgpair.right_coords.e.units) == "Unit(Ha)" + # gradient should also be updated + assert imgpair.left_coords.g is not None + assert imgpair.right_coords.g is not None + + # since imgpair takes a copy of initial species they + # should not be affected + assert mol1.energy is None + assert mol2.energy is None + assert mol1.gradient is None + assert mol2.gradient is None + + +@requires_working_xtb_install +@work_in_tmp_dir() +def test_imgpair_calc_hess(): + mol1 = Molecule(smiles="N#N") + + imgpair = NullImagePair(mol1, mol1.copy()) + imgpair.set_method_and_n_cores(method=XTB(), n_cores=1, hess_method=XTB()) + imgpair.update_both_img_hessian_by_calc() + + # should not change gradient and energy + assert imgpair.left_coords.g is None + assert imgpair.left_coords.e is None + # only hessian should be calculation + assert imgpair.left_coords.h is not None + + +@requires_working_xtb_install +def test_calculation_functions(): + # Test the external functions that are used in image pair for + # easy parallelisation + mol = Molecule(smiles="CCO") + en, grad = _calculate_engrad_for_species(mol, XTB(), 1) + assert en == mol.energy + assert grad == mol.gradient + + mol = Molecule(smiles="N#N") + hess = _calculate_hessian_for_species(mol, XTB(), 1) + # should take a copy to calculate + assert mol.energy is None + assert mol.gradient is None + assert mol.hessian is None + assert hess is not None + assert hess.shape == (3 * mol.n_atoms, 3 * mol.n_atoms) + + +@work_in_zipped_dir(datazip) +def test_hessian_update(): + mol1 = Molecule( + atoms=[ + Atom("N", 0.5588, 0.0000, 0.0000), + Atom("N", -0.5588, 0.0000, 0.0000), + ] + ) + g = np.loadtxt("n2_grad.txt") + h = np.loadtxt("n2_hess.txt") + + imgpair = NullImagePair(mol1, mol1.copy()) + imgpair.set_method_and_n_cores(method=XTB(), n_cores=1, hess_method=XTB()) + + imgpair.left_coords.g = g.copy() + imgpair.left_coords.h = h.copy() + imgpair.right_coords.g = g.copy() + imgpair.right_coords.h = h.copy() + + assert imgpair.left_coords.h is not None + + coord = imgpair.left_coords.copy() + coord[0] += 0.1 + + imgpair.left_coords = coord + imgpair.right_coords = coord + + new_g = np.loadtxt("n2_new_grad.txt") + + assert imgpair.left_coords.h is None + imgpair.left_coords.g = new_g.copy() + imgpair.right_coords.g = new_g.copy() + + # update the hessian with update formula + imgpair.update_both_img_hessian_by_formula() + assert imgpair.left_coords.h is not None + assert imgpair.right_coords.h is not None diff --git a/autodE/source/tests/test_calculation.py b/autodE/source/tests/test_calculation.py new file mode 100644 index 0000000000000000000000000000000000000000..9d29ca3e827facfdab94cde34f1c574ca3f8fb08 --- /dev/null +++ b/autodE/source/tests/test_calculation.py @@ -0,0 +1,594 @@ +import numpy as np +import pytest +import os +import sys +from autode.calculations import Calculation +from autode.calculations.output import ( + CalculationOutput, + BlankCalculationOutput, +) +from autode.calculations.executors import ( + CalculationExecutor, + CalculationExecutorO, +) +from autode.solvent.solvents import get_solvent +from autode.constraints import Constraints +from autode.wrappers.keywords.functionals import Functional +from autode.wrappers.methods import Method +from autode.utils import run_external +from autode.atoms import Atom +from autode.methods import XTB, ORCA +from autode.species import Molecule +from autode.config import Config +import autode.exceptions as ex +from autode.utils import work_in_tmp_dir +from .testutils import requires_working_xtb_install +from autode.wrappers.keywords import ( + SinglePointKeywords, + HessianKeywords, + GradientKeywords, + KeywordsSet, +) + + +def h_atom() -> Molecule: + return Molecule(atoms=[Atom("H")], mult=2) + + +def h2o() -> Molecule: + return Molecule(smiles="O", name="test_mol") + + +@work_in_tmp_dir() +def test_calc_class(): + xtb = XTB() + test_mol = h2o() + + calc = Calculation( + name="-tmp", molecule=test_mol, method=xtb, keywords=xtb.keywords.sp + ) + + # Should prepend a dash to appease some EST methods + assert not calc._executor.name.startswith("-") + assert calc.molecule is not None + assert calc.method.name == "xtb" + assert len(calc.input.filenames) == 0 + + # Without + assert test_mol.energy is None + assert test_mol.gradient is None + assert test_mol.hessian is None + + # With a filename that doesn't exist a NoOutput exception should be raised + calc.output.filename = "/a/path/that/does/not/exist/tmp" + with pytest.raises(ex.NoCalculationOutput): + _ = calc.output.file_lines + + # With no output should not be able to get properties + with open("tmp.out", "w") as output_file: + print("some\ntest\noutput", file=output_file) + + with pytest.raises(ex.CalculationException): + # Cannot set even the energy from an invalid output file + calc.set_output_filename("tmp.out") + + # Should default to a single core + assert calc.n_cores == 1 + + calc_str = str(calc) + new_calc = Calculation( + name="tmp2", molecule=test_mol, method=xtb, keywords=xtb.keywords.sp + ) + new_calc_str = str(new_calc) + # Calculation strings need to be unique + assert new_calc_str != calc_str + + new_calc = Calculation( + name="tmp2", molecule=test_mol, method=xtb, keywords=xtb.keywords.opt + ) + assert str(new_calc) != new_calc_str + + mol_no_atoms = Molecule() + with pytest.raises(ex.NoInputError): + _ = Calculation( + name="tmp2", + molecule=mol_no_atoms, + method=xtb, + keywords=xtb.keywords.sp, + ) + + +def test_calc_copy(): + orca = ORCA() + test_mol = h2o() + + calc = Calculation( + name="tmp", molecule=test_mol, method=orca, keywords=orca.keywords.sp + ) + + copied_calc = calc.copy() + copied_calc.input.keywords = None + + assert calc.input.keywords is not None + + +@work_in_tmp_dir(filenames_to_copy=[], kept_file_exts=[]) +def test_clear_output(): + with open("tmp.out", "w") as out_file: + print("some", "test", "output", sep="\n", file=out_file) + + output = CalculationOutput(filename="tmp.out") + assert output.exists + + assert len(output.file_lines) == 3 + + with open("tmp.out", "w") as out_file: + print("new output", sep="\n", file=out_file) + + # Without clearing the output then the file lines are not updated + assert len(output.file_lines) == 3 + + # Clearing the output will clear the cached property (file_lines). Lines + # are reloaded when the file_lines property is accessed again + output.clear() + + assert output.exists + assert len(output.file_lines) == 1 + + os.remove("tmp.out") + + +def test_distance_const_check(): + # Cannot have distance constraints between identical atoms + assert Constraints(distance={(0, 0): 0.0}, cartesian=None).distance is None + + assert ( + len( + Constraints( + distance={(0, 0): 0.0, (1, 0): 1.0}, cartesian=None + ).distance + ) + == 1 + ) + + +def test_calc_string(): + xtb = XTB() + test_mol = h2o() + + a = test_mol.copy() + no_const = Calculation( + name="tmp", molecule=a, method=xtb, keywords=xtb.keywords.sp + ) + + b = test_mol.copy() + b.constraints.cartesian = [0] + cart_const = Calculation( + name="tmp", molecule=b, method=xtb, keywords=xtb.keywords.sp + ) + + c = test_mol.copy() + c.constraints.distance = {(0, 1): 1.0} + dist_const = Calculation( + name="tmp", molecule=c, method=xtb, keywords=xtb.keywords.sp + ) + + d = test_mol.copy() + d.constraints.distance = {(0, 1): 1.5} + dist_const2 = Calculation( + name="tmp", molecule=d, method=xtb, keywords=xtb.keywords.sp + ) + + assert str(no_const) == str(no_const) + assert str(no_const) != str(cart_const) + assert str(no_const) != str(dist_const) + assert str(cart_const) != str(dist_const) + assert str(dist_const) != str(dist_const2) + + +@work_in_tmp_dir() +def test_fix_unique(): + """So calculations with different input but the same name are not skipped + autodE checks the input of each previously run calc with the name name""" + + orca = ORCA() + test_mol = h2o() + + calc = CalculationExecutor( + name="tmp", molecule=test_mol, method=orca, keywords=orca.keywords.sp + ) + calc._fix_unique() + assert calc.name == "tmp_orca" + + # Should generate a register + assert os.path.exists(".autode_calculations") + assert len(open(".autode_calculations", "r").readlines()) == 1 + + calc = CalculationExecutor( + name="tmp", molecule=test_mol, method=orca, keywords=orca.keywords.opt + ) + calc._fix_unique() + assert calc.name != "tmp_orca" + assert calc.name == "tmp_orca0" + + # no need to fix unique if the name is different + calc = CalculationExecutor( + name="tmp2", molecule=test_mol, method=orca, keywords=orca.keywords.opt + ) + calc._fix_unique() + assert calc.name == "tmp2_orca" + + +def test_solvent_get(): + xtb = XTB() + _test_mol = h2o() + + # Can't get the name of a solvent if molecule.solvent is not a string + with pytest.raises(ex.SolventUnavailable): + _test_mol.solvent = 5 + + with pytest.raises(ex.SolventNotFound): + _test_mol.solvent = "a_solvent_that_doesnt_exist" + + # Should work fine with a normal solvent + _test_mol.solvent = get_solvent("water", kind="implicit") + assert _test_mol.solvent.xtb.lower() in ["water", "h2o"] + + # Currently iodoethane is not in XTB - might be in the future + _test_mol.solvent = "iodoethane" + assert _test_mol.solvent.xtb is None + assert _test_mol.solvent.is_implicit + + with pytest.raises(ex.SolventUnavailable): + _ = Calculation( + "test", molecule=_test_mol, method=xtb, keywords=xtb.keywords.sp + ) + + +@work_in_tmp_dir() +def test_input_gen(): + xtb = XTB() + test_mol = h2o() + + calc = Calculation( + name="tmp", molecule=test_mol, method=xtb, keywords=xtb.keywords.sp + ) + + Config.keep_input_files = True + calc.generate_input() + assert os.path.exists("tmp_xtb.xyz") + calc.clean_up() + # Clean-up should do nothing if keep_input_files = True + assert os.path.exists("tmp_xtb.xyz") + + # but should be able to be forced + calc.clean_up(force=True) + assert not os.path.exists("tmp_xtb.xyz") + + # Test the keywords parsing + unsupported_func = Functional("PBE", orca="PBE") + calc_kwds = Calculation( + name="tmp", + molecule=test_mol, + method=xtb, + keywords=SinglePointKeywords([unsupported_func]), + ) + + with pytest.raises(ex.UnsupportedCalculationInput): + calc_kwds.generate_input() + + +@work_in_tmp_dir() +def test_exec_not_avail_method(): + orca = ORCA() + test_mol = h2o() + + orca.path = "/a/non/existent/path" + assert not orca.is_available + + calc = Calculation( + name="tmp", molecule=test_mol, method=orca, keywords=orca.keywords.sp + ) + calc.generate_input() + + with pytest.raises(ex.MethodUnavailable): + calc._executor.run() + + with pytest.raises(ex.MethodUnavailable): + calc.run() + + +@work_in_tmp_dir() +def test_exec_too_much_memory_requested_above_py39(): + if sys.version_info.minor < 9: + return # Only supported on Python 3.9 and above + + # Normal external run should be fine + run_external(["whoami"], output_filename="tmp.txt") + + Config.max_core = 10000000000000 + + # But if there is not enough physical memory it should raise an exception + with pytest.raises(RuntimeError): + run_external(["whoami"], output_filename="tmp.txt") + + +@requires_working_xtb_install +@work_in_tmp_dir() +def test_calculations_have_unique_names(): + xtb = XTB() + mol = Molecule(smiles="O") + + mol.single_point(method=xtb) + mol.single_point(method=xtb) # calculation should be skipped + + """For some insane reason the following code works if executed in python + directly but not if run within pytest""" + # neutral_energy = mol.energy.copy() + # + # mol.charge = 1 + # mol.single_point(method=xtb) # Calculation should be rerun + # cation_energy = mol.energy + # assert cation_energy > neutral_energy + + +@requires_working_xtb_install +@work_in_tmp_dir() +def test_numerical_hessian_evaluation(): + h2 = Molecule(atoms=[Atom("H"), Atom("H", x=1.0)]) + calc = Calculation( + name="h2_hess", molecule=h2, method=XTB(), keywords=HessianKeywords() + ) + calc.run() + + assert h2.hessian is not None + assert h2.hessian.shape == (6, 6) + assert np.allclose(h2.hessian, h2.hessian.T) + + orca_anal_hess = np.array( + [ + [ + 7.2267e-02, + -1.6028e-11, + 1.1456e-12, + -7.2267e-02, + 1.6078e-11, + -1.0933e-12, + ], + [ + -1.6027e-11, + 4.4978e-02, + -7.0964e-12, + 1.6039e-11, + -4.4978e-02, + 7.0964e-12, + ], + [ + 1.6868e-12, + -7.0964e-12, + 4.4978e-02, + -1.6754e-12, + 7.0964e-12, + -4.4978e-02, + ], + [ + -7.2267e-02, + -3.4360e-12, + 2.9458e-11, + 7.2267e-02, + 3.4061e-12, + -2.9411e-11, + ], + [ + -4.9340e-12, + -4.4978e-02, + -1.0574e-12, + 4.9333e-12, + 4.4978e-02, + 1.0574e-12, + ], + [ + 3.1763e-11, + -1.0574e-12, + -4.4978e-02, + -3.1762e-11, + 1.0575e-12, + 4.4978e-02, + ], + ] + ) + + def ms(x): + return np.mean(np.square(x)) + + assert ( + ms(orca_anal_hess - h2.hessian) + / (max((ms(orca_anal_hess), ms(h2.hessian)))) + ) < 0.5 + + +@work_in_tmp_dir() +def test_check_properties_exist_did_not_terminate_normally(): + calc = Calculation( + name="tmp", + molecule=h_atom(), + method=XTB(), + keywords=SinglePointKeywords(), + ) + + with pytest.raises(ex.CouldNotGetProperty): + calc._check_properties_exist() + + +class TestCalculator(Method): + __test__ = False + + def __init__(self): + super().__init__(name="test", doi_list=[], keywords_set=KeywordsSet()) + + @property + def uses_external_io(self) -> bool: + return False + + def execute(self, calc: "CalculationExecutor") -> None: + pass + + def terminated_normally_in(self, calc: "CalculationExecutor") -> bool: + return True + + def __repr__(self): + pass + + def implements(self, calculation_type) -> bool: + return True # this calculator 'implements' all calculations + + +class TestCalculatorConstantEnergy(TestCalculator): + def execute(self, calc) -> None: + calc.molecule.energy = 1.0 + + +class TestCalculatorConstantGradient(TestCalculator): + def execute(self, calc) -> None: + calc.molecule.energy = 1.0 + calc.molecule.gradient = np.zeros_like(calc.molecule.coordinates) + + +def _test_calc_with_keywords_type(_type, mol=h_atom()): + return Calculation( + name="tmp", molecule=mol, method=TestCalculator(), keywords=_type() + ) + + +def _test_calc(): + return _test_calc_with_keywords_type(SinglePointKeywords) + + +def test_generate_input_for_method_with_no_external_io(): + calc = _test_calc_with_keywords_type(SinglePointKeywords) + # should be able to call generate input without any exceptions + calc.generate_input() + + +def test_exception_raised_when_properties_dont_exist_after_run(): + h = h_atom() + + for _type in (SinglePointKeywords, GradientKeywords, HessianKeywords): + # Test method does not set any properties so these should fail + calc = _test_calc_with_keywords_type(_type, mol=h) + with pytest.raises(ex.CouldNotGetProperty): + calc.run() + + +def test_exception_raised_when_energy_but_no_grad_after_run(): + calc = Calculation( + name="tmp", + molecule=h_atom(), + method=TestCalculatorConstantEnergy(), + keywords=GradientKeywords(), + ) + + with pytest.raises(ex.CouldNotGetProperty): + calc.run() + + +def test_exception_raised_when_energy_grad_but_no_hess_after_run(): + calc = Calculation( + name="tmp", + molecule=h_atom(), + method=TestCalculatorConstantGradient(), + keywords=HessianKeywords(), + ) + + with pytest.raises(ex.CouldNotGetProperty): + calc.run() + + +def test_blank_calculation_output_with_no_external_io(): + h = h_atom() + assert h.energy is None + + calc = Calculation( + name="tmp", + molecule=h, + method=TestCalculatorConstantEnergy(), + keywords=SinglePointKeywords(), + ) + calc.run() + assert h.energy is not None + + assert calc.output.filename is None + assert len(calc.output.file_lines) == 0 + + +@work_in_tmp_dir() +def test_deleting_output_that_doesnt_exist(): + xtb = XTB() + calc = Calculation( + name="tmp2", molecule=h_atom(), method=xtb, keywords=xtb.keywords.sp + ) + + calc.input.additional_filenames = ["a", "b"] + # no exceptions should be raised trying to delete files that don't exist + calc.clean_up(force=True) + + +def test_blank_calculation_output_always_exists(): + assert BlankCalculationOutput().exists + + +@work_in_tmp_dir() +def test_fix_unique_with_resigter(): + env_var = os.environ.get("AUTODE_FIXUNIQUE", "False") + + calc = _test_calc()._executor + init_name = calc.name + calc._fix_unique() + assert os.path.exists(".autode_calculations") + + calc.input.keywords = ["a"] + calc._fix_unique() + second_name = calc.name + assert second_name != init_name + + calc = _test_calc()._executor + calc.input.keywords = ["a", "b"] + calc._fix_unique() + assert calc.name != init_name and calc.name != second_name + + # Reset the environment + os.environ["AUTODE_FIXUNIQUE"] = env_var + + +def test_non_external_io_method_can_force_cleanup(): + calc = _test_calc() + # No exceptions should be raised + calc.clean_up(force=True, everything=True) + + +def test_init_a_calculation_without_a_valid_spin_state_throws(): + xtb = XTB() + test_m = h_atom() + test_m.charge, test_m.mult = 0, 1 + + with pytest.raises(ex.CalculationException): + _ = Calculation( + name="tmp", molecule=test_m, method=xtb, keywords=xtb.keywords.sp + ) + + +def test_cannot_set_filename_on_a_blank_output(): + output = BlankCalculationOutput() + with pytest.raises(ValueError): + output.filename = "test" + + +def test_cannot_set_output_of_indirect_executor(): + orca = ORCA() + test_mol = h2o() + + executor = CalculationExecutorO( + name="tmp", molecule=test_mol, method=orca, keywords=orca.keywords.sp + ) + with pytest.raises(ValueError): + executor.output = BlankCalculationOutput() diff --git a/autodE/source/tests/test_comp_methods.py b/autodE/source/tests/test_comp_methods.py new file mode 100644 index 0000000000000000000000000000000000000000..4a9d96c37383bd8e5bfb6f4c4d7fc876e983713f --- /dev/null +++ b/autodE/source/tests/test_comp_methods.py @@ -0,0 +1,29 @@ +from autode.log.methods import methods +from autode.methods import ORCA +from autode import Molecule +from autode.config import Config +from .testutils import work_in_zipped_dir +import os + +here = os.path.dirname(os.path.abspath(__file__)) + + +def test_init(): + # Should contain some mention of autodE by default + assert "autodE" in str(methods) + + +@work_in_zipped_dir(os.path.join(here, "data", "comp_methods.zip")) +def test_dft(): + orca = ORCA() + + methods.clear() + h2 = Molecule(smiles="[H][H]", solvent_name="water") + h2.single_point(method=orca) + + assert "PBE0" in str(methods) + assert "def2-TZVP" in str(methods) + assert "4.2.1" in str(methods) + + # Default CPCM solvation in orca + assert "CPCM" in str(methods) diff --git a/autodE/source/tests/test_complex.py b/autodE/source/tests/test_complex.py new file mode 100644 index 0000000000000000000000000000000000000000..a422f08c44170d934ca79d7c8efb713dadd6605c --- /dev/null +++ b/autodE/source/tests/test_complex.py @@ -0,0 +1,259 @@ +import shutil +from autode.exceptions import NoConformers +from autode.species.complex import Complex, NCIComplex +from autode.config import Config +from autode.methods import XTB +from autode.species.molecule import Molecule +from autode.geom import are_coords_reasonable +from autode.atoms import Atom +from autode.values import Distance +from autode.utils import work_in_tmp_dir +import numpy as np +from . import testutils +from copy import deepcopy +import pytest + +h1 = Atom(atomic_symbol="H", x=0.0, y=0.0, z=0.0) +h2 = Atom(atomic_symbol="H", x=0.0, y=0.0, z=1.0) + +hydrogen = Molecule(name="H2", atoms=[h1, h2], charge=0, mult=1) +h = Molecule(name="H", atoms=[h1], charge=0, mult=2) + +monomer = Complex(hydrogen) +dimer = Complex(hydrogen, hydrogen) +trimer = Complex(hydrogen, hydrogen, hydrogen) + +h2_h = Complex(hydrogen, h) +h_h = Complex(h, h) + + +def test_complex_class(): + blank_complex = Complex() + assert blank_complex.n_molecules == 0 + assert blank_complex.solvent is None + assert blank_complex.atoms is None + assert blank_complex != "a" + + assert monomer.charge == 0 + assert monomer.mult == 1 + assert monomer.n_atoms == 2 + + assert repr(monomer) != "" # Have some simple representation + + assert h2_h.charge == 0 + assert h2_h.mult == 2 + assert h2_h.n_atoms == 3 + + assert h_h.mult == 3 + + assert trimer.n_atoms == 6 + + # Cannot have a complex in a different solvent + with pytest.raises(AssertionError): + h2_water = Molecule( + name="H2", atoms=[h1, h2], charge=0, mult=1, solvent_name="water" + ) + _ = Complex(hydrogen, h2_water) + + # Test solvent setting + dimer_solv = Complex(hydrogen, hydrogen, solvent_name="water") + assert dimer_solv.solvent is not None + assert dimer_solv.solvent.name == "water" + + +def test_complex_class_set(): + h2_complex = Complex(hydrogen, hydrogen, copy=True) + assert h2_complex.charge == 0 + assert h2_complex.mult == 1 + + # Cannot set the atoms of a (H2)2 complex with a single atom + with pytest.raises(ValueError): + h2_complex.atoms = [Atom("H")] + + with pytest.raises(ValueError): + h2_complex.atoms = [Atom("H"), Atom("H"), Atom("H")] + + with pytest.raises(ValueError): + h2_complex.atoms = [ + Atom("H"), + Atom("H"), + Atom("H"), + Atom("H"), + Atom("H"), + ] + + # but can with 4 atoms + h2_complex.atoms = [Atom("H"), Atom("H"), Atom("H"), Atom("H", x=10.0)] + assert h2_complex.n_atoms == 4 + assert h2_complex.n_molecules == 2 + assert h2_complex.distance(0, 3) == Distance(10.0, units="ang") + + # Setting no atoms should clear the complex + h2_complex.atoms = None + assert h2_complex.n_molecules == 0 + + +def test_translation(): + # Monomer translation + monomer_copy = deepcopy(monomer) + monomer_copy.translate_mol(vec=np.array([1.0, 0.0, 0.0]), mol_index=0) + + assert ( + np.linalg.norm(monomer_copy.atoms[0].coord - np.array([1.0, 0.0, 0.0])) + < 1e-9 + ) + assert ( + np.linalg.norm(monomer_copy.atoms[1].coord - np.array([1.0, 0.0, 1.0])) + < 1e-9 + ) + + # Dimer translation + dimer_copy = deepcopy(dimer) + dimer_copy.translate_mol(vec=np.array([1.0, 0.0, 0.0]), mol_index=0) + + assert ( + np.linalg.norm(dimer_copy.atoms[0].coord - np.array([1.0, 0.0, 0.0])) + < 1e-9 + ) + assert ( + np.linalg.norm(dimer_copy.atoms[1].coord - np.array([1.0, 0.0, 1.0])) + < 1e-9 + ) + + # Cannot translate molecule index 2 in a complex with only 2 molecules + with pytest.raises(Exception): + dimer_copy.translate_mol(vec=np.array([1.0, 0.0, 0.0]), mol_index=2) + + +def test_rotation(): + dimer_copy = deepcopy(dimer) + with pytest.raises(Exception): + dimer_copy.rotate_mol(mol_index=3, axis=[1.0, 1.0, 1.0], theta=0) + + dimer_copy.rotate_mol( + axis=np.array([1.0, 0.0, 0.0]), + theta=np.pi, + origin=np.array([0.0, 0.0, 0.0]), + mol_index=0, + ) + + expected_coords = np.array([[0.0, 0.0, 0.0], [0.0, 0.0, -1.0]]) + + assert np.sum(expected_coords - dimer_copy.coordinates[[0, 1], :]) < 1e-9 + + +def test_graph(): + hydrogen2 = deepcopy(hydrogen) + hydrogen2.translate(vec=np.array([10, 0, 0])) + + dimer_shifted = Complex(hydrogen, hydrogen2) + assert hasattr(dimer_shifted, "graph") + assert dimer_shifted.graph.number_of_edges() == 0 + assert dimer_shifted.graph.number_of_nodes() == 4 + + +def test_init_geometry(): + water = Molecule(smiles="O") + assert are_coords_reasonable(coords=Complex(water).coordinates) + + water_dimer = Complex(water, water, do_init_translation=True) + # water_dimer.print_xyz_file(filename='tmp.xyz') + assert are_coords_reasonable(coords=water_dimer.coordinates) + + +def test_conformer_generation(): + Config.num_complex_random_rotations = 2 + Config.num_complex_sphere_points = 6 + Config.max_num_complex_conformers = 10000 + + trimer._generate_conformers() + assert len(trimer.conformers) == 6**2 * 2**2 + + # all_atoms = [] + # for conf in trimer.conformers: + # all_atoms += conf.atoms + + # from autode.input_output import atoms_to_xyz_file + # atoms_to_xyz_file(atoms=all_atoms, filename='tmp.xyz') + + +def test_conformer_generation2(): + Config.num_complex_random_rotations = 1 + Config.num_complex_sphere_points = 6 + Config.max_num_complex_conformers = 10000 + + dimer._generate_conformers() + assert len(dimer.conformers) == 6 + + Config.num_complex_random_rotations = 2 + Config.max_num_complex_conformers = 10000 + + dimer._generate_conformers() + assert len(dimer.conformers) == 6 * 2 + + +def test_complex_init(): + h2o = Molecule( + name="water", atoms=[Atom("O"), Atom("H", x=-1), Atom("H", x=1)] + ) + + h2o_dimer = Complex(h2o, h2o, do_init_translation=False, copy=False) + h2o.translate([1.0, 0.0, 0.0]) + + # Shifting one molecule without a copy should result in both molecules + # within the complex being translated, thus the O-O distance being 0 + assert h2o_dimer.distance(0, 3) == 0.0 + + # (check the atoms have moved) + assert np.linalg.norm(h2o_dimer.atoms[0].coord) > 0.9 + + # but not if the molecules are copied + h2o = Molecule( + name="water", atoms=[Atom("O"), Atom("H", x=-1), Atom("H", x=1)] + ) + h2o_dimer = Complex(h2o, h2o, do_init_translation=False, copy=True) + + h2o_dimer.translate_mol([1.0, 0.0, 0.0], mol_index=1) + assert h2o_dimer.distance(0, 3) > 0.9 + + # (original molecule should not have moved + assert -1e-4 < np.linalg.norm(h2o.atoms[0].coord) < 1e-4 + + +def test_complex_atom_reorder(): + hf_dimer = Complex( + Molecule(name="HF", atoms=[Atom("H"), Atom("F", x=1.0)]), + Molecule(name="HF", atoms=[Atom("H"), Atom("F", x=1.0)]), + ) + + with pytest.raises(Exception): + _ = hf_dimer.atom_indexes(2) # molecules are indexed from 0 + + assert [atom.label for atom in hf_dimer.atoms] == ["H", "F", "H", "F"] + + hf_dimer.reorder_atoms(mapping={0: 1, 1: 0, 2: 2, 3: 3}) + assert [atom.label for atom in hf_dimer.atoms] == ["F", "H", "H", "F"] + assert hf_dimer.n_molecules == 2 + + +@work_in_tmp_dir(filenames_to_copy=[], kept_file_exts=[]) +@testutils.requires_working_xtb_install +def test_allow_connectivity_change(): + xtb = XTB() + xtb.path = shutil.which("xtb") + assert xtb.is_available + + na_h2o = NCIComplex(Molecule(smiles="[Na+]"), Molecule(smiles="O")) + + # Should prune connectivity change + try: + na_h2o.find_lowest_energy_conformer(lmethod=xtb) + assert na_h2o.n_conformers == 0 + + # Will fail to set the lowest energy conformer + except (NoConformers, RuntimeError): + pass + + # but should generate more conformers allowing the Na-OH2 'bond' + na_h2o.find_lowest_energy_conformer(allow_connectivity_changes=True) + assert na_h2o.n_conformers > 0 diff --git a/autodE/source/tests/test_conf_gen.py b/autodE/source/tests/test_conf_gen.py new file mode 100644 index 0000000000000000000000000000000000000000..d7701406a690e29b99c1838025587888f9a5e9c3 --- /dev/null +++ b/autodE/source/tests/test_conf_gen.py @@ -0,0 +1,334 @@ +from autode.atoms import Atom +from autode.conformers import conf_gen +from autode.species.molecule import Molecule +from autode.species.molecule import Reactant, Product +from autode.species.complex import ReactantComplex, ProductComplex +from autode.config import Config +from autode.geom import calc_rmsd +from autode.geom import are_coords_reasonable +from autode.transition_states.ts_guess import TSguess +from autode.transition_states.transition_state import TransitionState +from autode.bond_rearrangement import BondRearrangement +import numpy as np +import os + +here = os.path.dirname(os.path.abspath(__file__)) + +butane = Molecule( + name="butane", + charge=0, + mult=1, + atoms=[ + Atom("C", -0.63938, -0.83117, 0.06651), + Atom("C", 0.89658, -0.77770, 0.06222), + Atom("H", -0.95115, -1.71970, 0.65729), + Atom("H", -1.01425, -0.95802, -0.97234), + Atom("C", -1.28709, 0.40256, 0.69550), + Atom("H", 1.27330, -1.74033, -0.34660), + Atom("H", 1.27226, -0.67376, 1.10332), + Atom("C", 1.46136, 0.35209, -0.79910), + Atom("H", 1.10865, 0.25011, -1.84737), + Atom("H", 2.57055, 0.30082, -0.79159), + Atom("H", 1.16428, 1.34504, -0.40486), + Atom("H", -0.93531, 0.53113, 1.74115), + Atom("H", -2.38997, 0.27394, 0.70568), + Atom("H", -1.05698, 1.31807, 0.11366), + ], +) + +methane = Molecule( + name="methane", + charge=0, + mult=1, + atoms=[ + Atom("C", 0.70879, 0.95819, -0.92654), + Atom("H", 1.81819, 0.95820, -0.92655), + Atom("H", 0.33899, 0.14642, -0.26697), + Atom("H", 0.33899, 0.79287, -1.95935), + Atom("H", 0.33899, 1.93529, -0.55331), + ], +) + + +def test_bcp_confs(tmpdir): + os.chdir(tmpdir) + + mol = Molecule(smiles="C1CC2C1C2") + mol.rdkit_conf_gen_is_fine = False + mol.populate_conformers(n_confs=100) + + assert all(conf.energy is not None for conf in mol.conformers) + energies = np.array([conf.energy for conf in mol.conformers]) + + # Hard coded standard deviation as, once the pruning has happened then + # the standard deviation is different + avg, std = np.average(energies), 0.005 + + # This fused ring system has a reasonable probability of generating a + # high energy conformer with RR, with a minimum that is very congested + # it should be removed when the conformers are set + assert all(np.abs(conf.energy - avg) / std < 5 for conf in mol.conformers) + assert mol.n_conformers > 0 + + os.chdir(here) + + +def test_setero_metal(tmpdir): + os.chdir(tmpdir) + + # (R)-sec butyl lithium + mol = Molecule(smiles="[Li][C@H](C)CC") + mol.print_xyz_file() + assert are_coords_reasonable(coords=mol.coordinates) + + os.chdir(here) + + +def test_conf_gen(tmpdir): + os.chdir(tmpdir) + + atoms = conf_gen.get_simanl_atoms(species=methane) + assert len(atoms) == 5 + assert os.path.exists("methane_conf0_siman.xyz") + + # Rerunning the conformer generation should read the generated .xyz file + atoms = conf_gen.get_simanl_atoms(species=methane) + assert len(atoms) == 5 + + os.remove("methane_conf0_siman.xyz") + + # Ensure the new graph is identical + regen = Molecule(name="regenerated_methane", atoms=atoms) + + assert regen.graph.edges == methane.graph.edges + assert regen.graph.nodes == methane.graph.nodes + + # Should be able to generate a conformer directly using the method + conf = conf_gen.get_simanl_conformer(species=methane) + assert len(atoms) == 5 + assert are_coords_reasonable(conf.coordinates) + assert conf.energy is not None + assert conf.solvent is None + + os.remove("methane_conf0_siman.xyz") + + os.chdir(here) + + +def test_conf_gen_dist_const(tmpdir): + os.chdir(tmpdir) + + hydrogen = Molecule( + name="H2", + charge=0, + mult=1, + atoms=[Atom(atomic_symbol="H"), Atom(atomic_symbol="H", z=0.7)], + ) + + # H2 at a bond length (r) of 0.7 Å has a bond + assert len(hydrogen.graph.edges) == 1 + + # H2 at r = 2 Å is definitely not bonded + atoms = conf_gen.get_simanl_atoms( + species=hydrogen, dist_consts={(0, 1): 2} + ) + + long_hydrogen = Molecule(name="H2", atoms=atoms, charge=0, mult=1) + assert long_hydrogen.n_atoms == 2 + assert len(long_hydrogen.graph.edges) == 0 + + os.chdir(here) + + +def test_chiral_rotation(tmpdir): + os.chdir(tmpdir) + + chiral_ethane = Molecule( + name="chiral_ethane", + charge=0, + mult=1, + atoms=[ + Atom("C", -0.26307, 0.59858, -0.07141), + Atom("C", 1.26597, 0.60740, -0.09729), + Atom("Cl", -0.91282, 2.25811, 0.01409), + Atom("F", -0.72365, -0.12709, 1.01313), + Atom("H", -0.64392, 0.13084, -1.00380), + Atom("Cl", 1.93888, 1.31880, 1.39553), + Atom("H", 1.61975, 1.19877, -0.96823), + Atom("Br", 1.94229, -1.20011, -0.28203), + ], + ) + + chiral_ethane.graph.nodes[0]["stereo"] = True + chiral_ethane.graph.nodes[1]["stereo"] = True + + atoms = conf_gen.get_simanl_atoms(chiral_ethane) + regen = Molecule(name="regenerated_ethane", charge=0, mult=1, atoms=atoms) + + regen_coords = regen.coordinates + coords = chiral_ethane.coordinates + + # Atom indexes of the C(C)(Cl)(F)(H) chiral centre + ccclfh = [0, 1, 2, 3, 4] + + # Atom indexes of the C(C)(Cl)(Br)(H) chiral centre + ccclbrh = [1, 0, 5, 7, 6] + + for centre_idxs in [ccclfh, ccclbrh]: + # Ensure the fragmented centres map almost identically + # if calc_rmsd(template_coords=coords[centre_idxs], coords_to_ + # fit=regen_coords[centre_idxs]) > 0.5: + # chiral_ethane.print_xyz_file(filename=os.path.join(here, + # 'chiral_ethane.xyz')) + # regen.print_xyz_file(filename=os.path.join(here, 'regen.xyz')) + + # RMSD on the 5 atoms should be < 0.5 Å + assert ( + calc_rmsd( + coords1=coords[centre_idxs], coords2=regen_coords[centre_idxs] + ) + < 0.5 + ) + + os.chdir(here) + + +def test_butene(tmpdir): + os.chdir(tmpdir) + + butene = Molecule( + name="z-but-2-ene", + charge=0, + mult=1, + atoms=[ + Atom("C", -1.69185, -0.28379, -0.01192), + Atom("C", -0.35502, -0.40751, 0.01672), + Atom("C", -2.39437, 1.04266, -0.03290), + Atom("H", -2.13824, 1.62497, 0.87700), + Atom("H", -3.49272, 0.88343, -0.05542), + Atom("H", -2.09982, 1.61679, -0.93634), + Atom("C", 0.57915, 0.76747, 0.03048), + Atom("H", 0.43383, 1.38170, -0.88288), + Atom("H", 1.62959, 0.40938, 0.05452), + Atom("H", 0.39550, 1.39110, 0.93046), + Atom("H", -2.29700, -1.18572, -0.02030), + Atom("H", 0.07422, -1.40516, 0.03058), + ], + ) + + butene.graph.nodes[0]["stereo"] = True + butene.graph.nodes[1]["stereo"] = True + + # Conformer generation should retain the stereochemistry + atoms = conf_gen.get_simanl_atoms(species=butene) + regen = Molecule(name="regenerated_butene", atoms=atoms, charge=0, mult=1) + + regen.print_xyz_file() + regen_coords = regen.coordinates + + # The Z-butene isomer has a r(C_1 C_2) < 3.2 Å where C_1C=CC_2 + assert np.linalg.norm(regen_coords[6] - regen_coords[2]) < 3.6 + + os.chdir(here) + + +def test_ts_conformer(tmpdir): + os.chdir(tmpdir) + + ch3cl = Reactant( + charge=0, + mult=1, + atoms=[ + Atom("Cl", 1.63664, 0.02010, -0.05829), + Atom("C", -0.14524, -0.00136, 0.00498), + Atom("H", -0.52169, -0.54637, -0.86809), + Atom("H", -0.45804, -0.50420, 0.92747), + Atom("H", -0.51166, 1.03181, -0.00597), + ], + ) + f = Reactant(charge=-1, mult=1, atoms=[Atom("F", 4.0, 0.0, 0.0)]) + + ch3f = Product( + charge=0, + mult=1, + atoms=[ + Atom("C", -0.05250, 0.00047, -0.00636), + Atom("F", 1.31229, -0.01702, 0.16350), + Atom("H", -0.54993, -0.04452, 0.97526), + Atom("H", -0.34815, 0.92748, -0.52199), + Atom("H", -0.36172, -0.86651, -0.61030), + ], + ) + cl = Reactant(charge=-1, mult=1, atoms=[Atom("Cl", 4.0, 0.0, 0.0)]) + + f_ch3cl_tsguess = TSguess( + reactant=ReactantComplex(f, ch3cl), + product=ProductComplex(ch3f, cl), + atoms=[ + Atom("F", -2.66092, -0.01426, 0.09700), + Atom("Cl", 1.46795, 0.05788, -0.06166), + Atom("C", -0.66317, -0.01826, 0.02488), + Atom("H", -0.78315, -0.58679, -0.88975), + Atom("H", -0.70611, -0.54149, 0.97313), + Atom("H", -0.80305, 1.05409, 0.00503), + ], + ) + + f_ch3cl_tsguess.bond_rearrangement = BondRearrangement( + breaking_bonds=[(2, 1)], forming_bonds=[(0, 2)] + ) + + f_ch3cl_ts = TransitionState(ts_guess=f_ch3cl_tsguess) + + atoms = conf_gen.get_simanl_atoms( + species=f_ch3cl_ts, dist_consts=f_ch3cl_ts.active_bond_constraints + ) + + regen = Molecule(name="regenerated_ts", charge=-1, mult=1, atoms=atoms) + + # Ensure the making/breaking bonds retain their length + regen_coords = regen.coordinates + assert are_coords_reasonable(regen_coords) is True + + assert 1.9 < np.linalg.norm(regen_coords[0] - regen_coords[2]) < 2.1 + assert 2.0 < np.linalg.norm(regen_coords[1] - regen_coords[2]) < 2.2 + + os.chdir(here) + + +def test_metal_eta_complex(tmpdir): + os.chdir(tmpdir) + + # eta-6 benzene Fe2+ complex used in the molassembler paper + m = Molecule( + smiles="[C@@H]12[C@H]3[C@H]4[C@H]5[C@H]6[C@@H]1[Fe]265437N" + "(C8=CC=CC=C8)C=CC=[N+]7C9=CC=CC=C9" + ) + m.print_xyz_file() + assert are_coords_reasonable(coords=m.coordinates) + + os.chdir(here) + + +def test_salt(): + salt = Molecule(name="salt", smiles="[Li][Br]") + assert salt.n_atoms == 2 + assert are_coords_reasonable(coords=salt.coordinates) + + +def test_potential(): + # Approximate H2 coordinates + bond_length = 0.7 + coords = np.array([[0.0, 0.0, 0.0], [0.0, 0.0, bond_length]]) + + eq_bond_length = 0.75 + d0 = np.array([[0.0, eq_bond_length], [eq_bond_length, 0.0]]) + + v = conf_gen._get_v( + coords, bonds=[(0, 1)], k=0.7, c=0.3, d0=d0, fixed_bonds=[], exponent=8 + ) + + expected_v = ( + 0.7 * (bond_length - eq_bond_length) ** 2 + 0.3 / bond_length**8 + ) + assert np.abs(v - expected_v) < 1e-6 diff --git a/autodE/source/tests/test_config.py b/autodE/source/tests/test_config.py new file mode 100644 index 0000000000000000000000000000000000000000..2ee6035c4055fdc07774eedc67b1ea5e39730b0c --- /dev/null +++ b/autodE/source/tests/test_config.py @@ -0,0 +1,116 @@ +import numpy as np +import pytest +from copy import deepcopy +from autode.config import Config, _instantiate_config_opts, _ConfigClass +from autode.values import Allocation, Distance +from autode.wrappers.keywords import KeywordsSet +from autode.wrappers.keywords import Keywords, Functional, BasisSet +from autode.utils import _copy_into_current_config +from autode.transition_states.templates import get_ts_template_folder_path + + +def test_config(): + keywords_attr = ["low_opt", "grad", "opt", "opt_ts", "hess", "sp"] + global_attr = ["max_core", "n_cores"] + + assert all([hasattr(Config, attr) for attr in global_attr]) + assert all([hasattr(Config.ORCA, attr) for attr in ["path", "keywords"]]) + + def assert_has_correct_keywords(keywords): + for attribute in keywords_attr: + assert hasattr(keywords, attribute) + assert isinstance(getattr(keywords, attribute), Keywords) + + assert isinstance(Config.ORCA.keywords, KeywordsSet) + assert_has_correct_keywords(keywords=Config.G09.keywords) + assert_has_correct_keywords(keywords=Config.MOPAC.keywords) + assert_has_correct_keywords(keywords=Config.XTB.keywords) + assert_has_correct_keywords(keywords=Config.ORCA.keywords) + assert_has_correct_keywords(keywords=Config.NWChem.keywords) + + +def test_maxcore_setter(): + _config = deepcopy(Config) + + # Cannot have a negative allocation + with pytest.raises(ValueError): + _config.max_core = -1 + + # Default units are megabytes + _config.max_core = 1 + assert int(_config.max_core.to("MB")) == 1 + assert "mb" in repr(_config.max_core.to("MB")) + + # and should be able to convert MB -> GB + _config.max_core = Allocation(1, units="GB") + assert int(_config.max_core.to("MB")) == 1000 + + +@pytest.mark.parametrize("factor", (-0.1, 1.1, "a string")) +def test_invalid_freq_scale_factor(factor): + with pytest.raises(Exception): + Config.freq_scale_factor = factor + + +def test_unknown_attr(): + # Attributes not already present should raise an exception e.g. for + # misspelling + with pytest.raises(Exception): + Config.maxcore = 1 + + +def test_step_size_setter(): + _config = deepcopy(Config) + + # Distances cannot be negative + with pytest.raises(ValueError): + _config.max_step_size = -0.11 + + # Setting the attribute should default to a Distance (Å) + _config.max_step_size = 0.1 + assert np.isclose(_config.max_step_size.to("ang"), 0.1) + + # Setting in Bohr should convert to angstroms + _config.max_step_size = Distance(0.2, units="a0") + assert np.isclose(_config.max_step_size.to("ang"), 0.1, atol=0.02) + + +def test_config_simple_copy(): + _config = deepcopy(Config) + _config_restore = deepcopy(Config) + + _config.n_cores = 31 + _config.ORCA.keywords.low_sp.basis_set = "aug-cc-pVTZ" + _config.NWChem.keywords.opt.functional = "B3LYP" + + assert Config.n_cores != 31 + assert Config.ORCA.keywords.low_sp.basis_set != BasisSet("aug-cc-pVTZ") + assert Config.NWChem.keywords.opt.functional != Functional("B3LYP") + + _copy_into_current_config(_config) + + assert Config.n_cores == 31 + assert Config.ORCA.keywords.low_sp.basis_set == BasisSet("aug-cc-pVTZ") + assert Config.NWChem.keywords.opt.functional == Functional("B3LYP") + + # restore original config + _copy_into_current_config(_config_restore) + + +def test_exc_if_not_class_in_config_instantiate_func(): + # passing instance should raise exception + with pytest.raises(ValueError): + _instantiate_config_opts(_ConfigClass()) + + # when passed class it should work + test_config = _instantiate_config_opts(_ConfigClass) + assert test_config is not None + + +def test_invalid_get_ts_template_folder_path(): + Config.ts_template_folder_path = "" + + with pytest.raises(ValueError): + _ = get_ts_template_folder_path(None) + + Config.ts_template_folder_path = None diff --git a/autodE/source/tests/test_conformers.py b/autodE/source/tests/test_conformers.py new file mode 100644 index 0000000000000000000000000000000000000000..cefa3c4e6241d39652278b26e94770f6f5ca44ce --- /dev/null +++ b/autodE/source/tests/test_conformers.py @@ -0,0 +1,419 @@ +from autode.atoms import Atom, Atoms +from autode.species import Molecule, NCIComplex +from autode.conformers import Conformer, Conformers +from autode.conformers.conformers import _calc_conformer +from autode.exceptions import NoConformers +from autode.wrappers.ORCA import ORCA +from autode.wrappers.XTB import XTB +from autode.config import Config +from autode.values import Energy +from autode.utils import work_in_tmp_dir +from autode.wrappers.keywords import SinglePointKeywords +from scipy.spatial import distance_matrix +from rdkit import Chem +from rdkit.Chem import AllChem +from autode.conformers.conformers import atoms_from_rdkit_mol +from . import testutils +import numpy as np +import pytest +import os +import shutil + +here = os.path.dirname(os.path.abspath(__file__)) +orca = ORCA() + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "conformers.zip")) +def test_conf_class(): + h2_conf = Conformer( + name="h2_conf", + charge=0, + mult=1, + atoms=[Atom("H", 0.0, 0.0, 0.0), Atom("H", 0.0, 0.0, 0.7)], + ) + + assert "conformer" in repr(h2_conf).lower() + assert hasattr(h2_conf, "optimise") + assert h2_conf != "a" + + assert h2_conf.n_atoms == 2 + assert h2_conf.energy is None + assert not h2_conf.constraints.any + + h2_conf.optimise(method=orca) + assert h2_conf.energy == -1.160780546661 + assert h2_conf.atoms is not None + assert h2_conf.n_atoms == 2 + + # Check that if the conformer calculation does not complete successfully + # then don't raise an exception for a conformer + h2_conf_broken = Conformer( + name="h2_conf_broken", + charge=0, + mult=1, + atoms=[Atom("H", 0.0, 0.0, 0.0), Atom("H", 0.0, 0.0, 0.7)], + ) + h2_conf_broken.optimise(method=orca) + + assert h2_conf_broken.atoms is None + assert h2_conf_broken.n_atoms == 0 + + +def test_conf_from_species(): + h2o = Molecule(smiles="O") + conformer = Conformer(species=h2o) + assert conformer.n_atoms == 3 + assert conformer.mult == 1 + assert conformer.charge == 0 + assert conformer.solvent is None + + h2o_cation = Molecule(smiles="[O+H2]", charge=1, solvent_name="water") + assert h2o_cation.charge == 1 + + conformer = Conformer(species=h2o_cation) + assert conformer.n_atoms == 3 + assert conformer.mult == 2 + assert conformer.charge == 1 + + assert conformer.solvent is not None + assert conformer.solvent.name == "water" + + +def test_rdkit_atoms(): + mol = Chem.MolFromSmiles("C") + mol = Chem.AddHs(mol) + + AllChem.EmbedMultipleConfs(mol, numConfs=1) + + atoms = atoms_from_rdkit_mol(rdkit_mol_obj=mol, conf_id=0) + assert len(atoms) == 5 + + coords = np.array([atom.coord for atom in atoms]) + dist_mat = distance_matrix(coords, coords) + + # No distance between the same atom + assert dist_mat[0, 0] == 0.0 + + # CH bond should be ~1 Å + assert 0.9 < dist_mat[0, 1] < 1.2 + + +def test_confs_energy_pruning1(): + conf1 = Conformer(atoms=[Atom("H")]) + confs = Conformers([conf1]) + + # Shouldn't prune a single conformer + confs.prune_on_rmsd() + assert len(confs) == 1 + confs.prune_on_energy() + assert len(confs) == 1 + + conf2 = Conformer(atoms=[Atom("H")]) + + # with no energies no conformers should be pruned on energy + confs = Conformers([conf1, conf2]) + confs.prune_on_energy() + assert len(confs) == 2 + + conf3 = Conformer(atoms=[Atom("H")]) + confs = Conformers([conf1, conf2, conf3]) + + # Set two energies the same and leave one as none.. + conf1.energy = 1 + conf2.energy = 1 + + # which should prune to two, one with energy = 1 and one energy = None + confs.prune_on_energy() + assert len(confs) == 2 + + # If they all have an energy then they should prune to a single conformer + conf3.energy = 1 + confs.prune_on_energy() + assert len(confs) == 1 + + +def test_confs_energy_pruning2(): + conf1 = Conformer(atoms=[Atom("H")]) + conf1.energy = 1 + assert conf1.energy.units == "Ha" + + conf2 = Conformer(atoms=[Atom("H")]) + conf2.energy = 1.1 + + confs = Conformers([conf1, conf2]) + assert len(confs) == 2 + + # If the threshold is smaller than the difference then should leave both + confs.prune_on_energy(e_tol=Energy(0.05, units="Ha")) + assert len(confs) == 2 + + # but not if the threshold is larger + confs.prune_on_energy(e_tol=Energy(0.2, units="Ha")) + assert len(confs) == 1 + + +def test_confs_energy_pruning3(): + n = 100 + + # μ α + energies = np.random.normal(loc=0.0, scale=0.1, size=n) + confs = Conformers([Conformer(atoms=[Atom("H")]) for _ in range(n)]) + for conf, energy in zip(confs, energies): + conf.energy = energy + + diff_e_conf = Conformer(atoms=[Atom("H")]) + diff_e_conf.energy = 3.0 + confs.append(diff_e_conf) + + # Should remove the conformer with the very different energy + confs.prune_on_energy(e_tol=Energy(1e-10), n_sigma=5) + assert len(confs) == 100 + + +def test_confs_no_energy_pruning(): + # Check that if energies are unassigned then conformers are removed + conf0 = Conformer(atoms=[Atom("H")]) + conf1 = conf0.copy() + conf1.energy = -0.5 + + confs = Conformers([conf0, conf1]) + confs.prune(remove_no_energy=True) + + assert len(confs) == 1 + + +def test_confs_rmsd_pruning1(): + confs = Conformers( + [Conformer(atoms=[Atom("H")]), Conformer(atoms=[Atom("H")])] + ) + + # Same two structures -> one when pruned + confs.prune_on_rmsd() + assert len(confs) == 1 + + +def test_confs_rmsd_pruning2(): + confs = Conformers( + [ + Conformer(atoms=[Atom("H", x=-1.0), Atom("O"), Atom("H", x=1.0)]), + Conformer(atoms=[Atom("H", x=-1.0), Atom("O"), Atom("H", x=10.0)]), + ] + ) + + # Should check only on heavy atoms, thus these two 'water' molecules + # have a 0 RMSD + confs.prune_on_rmsd() + assert len(confs) == 1 + + +def test_confs_rmsd_puning3(): + # Butane molecules, without hydrogen atoms + trans_butane = Conformer( + atoms=[ + Atom("C", -0.86310, -0.72859, 0.62457), + Atom("C", 0.10928, -0.05429, 1.42368), + Atom("C", 1.17035, -0.79134, 2.03167), + Atom("C", 2.14109, -0.11396, 2.83018), + ] + ) + + cis_butane = Conformer( + atoms=[ + Atom("C", -0.07267, 1.32686, 1.73687), + Atom("C", 0.10928, -0.05429, 1.42368), + Atom("C", 1.17035, -0.79134, 2.03167), + Atom("C", 2.14109, -0.11396, 2.83018), + ] + ) + + confs = Conformers([trans_butane, cis_butane]) + + confs.prune_on_rmsd(rmsd_tol=0.1) + assert len(confs) == 2 + + confs.prune_on_rmsd(rmsd_tol=0.5) + assert len(confs) == 1 + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "sp_conformers.zip")) +def test_sp_hmethod(): + Config.hmethod_sp_conformers = True + orca.keywords.low_sp = SinglePointKeywords(["PBE", "D3BJ", "def2-SVP"]) + + conf = Conformer(name="C4H10_conf0", species=Molecule(smiles="CCCC")) + + conf.single_point(method=orca) + assert conf.energy is not None + + Config.hmethod_sp_conformers = False + + +@testutils.requires_working_xtb_install +@testutils.work_in_zipped_dir(os.path.join(here, "data", "sp_conformers.zip")) +def test_sp_hmethod_ranking(): + Config.hmethod_sp_conformers = True + orca.keywords.low_sp = None + + butane = Molecule(smiles="CCCC") + xtb = XTB() + cwd = os.getcwd() + + # Need to set hmethod.low_sp for hmethod_sp_conformers + with pytest.raises(AssertionError): + butane.find_lowest_energy_conformer(lmethod=xtb, hmethod=orca) + + # work_in function will change directories and may not change back when an + # exception is raised, so ensure we're working in the same dir as before + os.chdir(cwd) + + orca.keywords.low_sp = SinglePointKeywords(["PBE", "D3BJ", "def2-SVP"]) + butane.find_lowest_energy_conformer(lmethod=xtb, hmethod=orca) + assert butane.energy is not None + + Config.hmethod_sp_conformers = False + + +def test_calculation_over_no_conformers(): + confs = Conformers() + confs.single_point(method=orca) + + # Should not raise an exception + assert len(confs) == 0 + + +def test_complex_conformers_diff_names(): + Config.num_complex_sphere_points = 2 + Config.num_complex_random_rotations = 2 + + water = Molecule(smiles="O") + h2o_dimer = NCIComplex(water, water, name="dimer") + h2o_dimer._generate_conformers() + assert len(set(conf.name for conf in h2o_dimer.conformers)) > 1 + + if os.path.exists("conformers"): + shutil.rmtree("conformers") + + +@testutils.requires_working_xtb_install +@work_in_tmp_dir(filenames_to_copy=[], kept_file_exts=[]) +def test_calc_conformer(): + h2_conf = Conformer( + name="h2_conf", + charge=0, + mult=1, + atoms=Atoms( + [ + Atom("H", 0.0, 0.0, 0.0), + Atom("H", 0.0, 0.0, 0.7), + ] + ), + ) + + _xtb = XTB() + _xtb.path = shutil.which("xtb") + assert _xtb.is_available + + h2_conf = _calc_conformer( + conformer=h2_conf, + calc_type="single_point", + method=_xtb, + keywords=_xtb.keywords.sp, + n_cores=1, + ) + + assert h2_conf.energy is not None + + +@testutils.requires_working_xtb_install +@work_in_tmp_dir(filenames_to_copy=[], kept_file_exts=[]) +def test_calc_conformer_that_fails_doesnt_raise(): + h2_conf = Conformer( + name="h_atom", + atoms=Atoms( + [ + Atom("H", 0.0, 0.0, 0.0), + Atom("H", 0.0, 0.0, 0.0), + ] + ), + ) + + _xtb = XTB() + h2_conf = _calc_conformer( + conformer=h2_conf, + calc_type="single_point", + method=_xtb, + keywords=_xtb.keywords.sp, + n_cores=1, + ) + assert h2_conf.energy is None + + +def test_conformers_inherit_atom_classes(): + def has_correct_atom_class(_species): + return sum(atom.atom_class == 1 for atom in _species.atoms) == 1 + + mol = Molecule(smiles="C[Br:1]") + assert has_correct_atom_class(mol) + + mol.populate_conformers(n_confs=2) + assert len(mol.conformers) > 0 # Should generate one conformer + + assert has_correct_atom_class(mol.conformers[0]) + + +def test_conformer_coordinate_setting_no_atoms(): + conf = Conformer() + assert conf.atoms is None + assert conf.coordinates is None + + # Cannot set the coordinates without any atoms + with pytest.raises(ValueError): + conf.coordinates = np.array([0.0, 0.0, 0.0]) + + +def test_conformer_coordinate_setting_with_atoms(): + # Setting the atoms should also set coordinates + conf = Conformer() + + conf.atoms = Atoms([Atom("H")]) + assert conf.coordinates is not None + assert np.allclose(conf.coordinates, np.zeros(shape=(1, 3))) + assert conf.atoms is not None + + # Discarding the atoms should also discard the coordinates + conf.atoms = None + assert conf.coordinates is None + + +def test_conformer_coordinate_setting_with_different_atomic_attr(): + # Atom classes should persist + conf = Conformer(species=Molecule(smiles="[C:9]")) + + def has_correct_atom_class(_species): + return sum(atom.atom_class == 9 for atom in _species.atoms) == 1 + + assert has_correct_atom_class(conf) + conf.atoms = Atoms([Atom("C")]) + assert has_correct_atom_class(conf) + conf.coordinates = np.ones_like(conf.coordinates) + assert has_correct_atom_class(conf) + + # But cannot set atoms with a different label (e.g. atomic symbol) + with pytest.raises(ValueError): + conf.atoms = Atoms([Atom("H")]) + + +def test_pruning_conformers_without_energy_raises(): + conformers = Conformers( + [Conformer(atoms=[Atom("H")]), Conformer(atoms=[Atom("H")])] + ) + + assert sum(c.energy is None for c in conformers) == 2 + + with pytest.raises(NoConformers): + conformers.prune(remove_no_energy=True) + + +def test_pruning_no_energy_with_no_conformers_is_possible(): + conformers = Conformers() + conformers.remove_no_energy() diff --git a/autodE/source/tests/test_const_opt.py b/autodE/source/tests/test_const_opt.py new file mode 100644 index 0000000000000000000000000000000000000000..584956d9cb014bfd5e869b4d83a15ac37b0d6225 --- /dev/null +++ b/autodE/source/tests/test_const_opt.py @@ -0,0 +1,25 @@ +import os +import numpy as np +from autode.input_output import xyz_file_to_atoms +from autode.transition_states.ts_guess import TSguess +from autode.methods import ORCA +from . import testutils + + +here = os.path.dirname(os.path.abspath(__file__)) + + +@testutils.work_in_zipped_dir( + os.path.join(here, "data", "constrained_opt.zip") +) +@testutils.requires_working_xtb_install +def test_constrained_opt(): + ts_guess = TSguess(atoms=xyz_file_to_atoms("h_shift_init.xyz"), mult=2) + ts_guess.run_constrained_opt( + name="const_opt", + distance_consts={(2, 6): 1.304, (6, 1): 1.295}, + method=ORCA(), + ) + + assert np.isclose(ts_guess.distance(2, 6), 1.304, atol=0.1) + assert np.isclose(ts_guess.distance(6, 1), 1.295, atol=0.1) diff --git a/autodE/source/tests/test_constraints.py b/autodE/source/tests/test_constraints.py new file mode 100644 index 0000000000000000000000000000000000000000..69aab80c573d0ed286bbf60ca91113440b89c3da --- /dev/null +++ b/autodE/source/tests/test_constraints.py @@ -0,0 +1,116 @@ +import pytest +from autode.constraints import Constraints, DistanceConstraints + + +def test_base_properties(): + consts = Constraints() + + assert "constraints" in str(consts).lower() + assert "constraints" in repr(consts).lower() + + assert not consts.any + assert consts.distance is None + assert consts.cartesian is None + + consts.update(distance={(0, 1): 1.0}) + assert consts.any + assert consts.distance is not None + assert consts.cartesian is None + + # Constraints should allow for setting + consts.distance = {(0, 1): 2} + assert int(consts.distance[(0, 1)]) == 2 + + # And be permutationally invariant + assert consts.distance[(0, 1)] == consts.distance[(1, 0)] + + with pytest.raises(Exception): + consts.distance = 1 + + with pytest.raises(Exception): + consts.distance = {(0, 1): -1.0} # Invalid distance + + with pytest.raises(Exception): + consts.distance = {0: 1.0} # Invalid atom index pair + + with pytest.raises(Exception): + consts.distance = {(0, -1): 1.0} # Invalid atom index (-1) + + # Non-unique pairs are skipped + consts.distance[(0,)] = 1.0 + assert len(consts.distance) == 1 + consts.distance[(0, 0)] = 1.0 + assert len(consts.distance) == 1 + + with pytest.raises(Exception): + consts.distance[(0, 1)] = -1.0 + + # Likewise with setting cartesian constraints + consts.cartesian = [0, 1] + assert int(consts.cartesian[0]) == 0 + + with pytest.raises(Exception): + consts.cartesian = 5 + + conts2 = Constraints(cartesian=[0], distance={(0, 1): 1.1}) + assert conts2.cartesian is not None and conts2.distance is not None + + assert "0" in str(conts2) and "1.1" in str(conts2) + + +def test_multiple_update(): + consts = Constraints() + consts.update(distance={(0, 1): 1.0}) + consts.update(distance={(1, 0): 1.0}) + assert len(consts.distance) == 1 + + # Non unique pairs should be skipped + consts.update(distance={(0, 0): 1.0}) + assert len(consts.distance) == 1 + + # Cannot have negative distances + with pytest.raises(ValueError): + consts.update(distance={(0, 1): -1.0}) + + +def test_cartesian_update(): + conts = Constraints(cartesian=[0, 1]) + + assert conts.any + assert len(conts.cartesian) == 2 + + # Should only have the unique components + conts.update(cartesian=[0]) + assert len(conts.cartesian) == 2 + + +def test_clear(): + # Like species.conformers setting to None should still allow future updates + + consts = Constraints(distance={(0, 1): 1.0}) + assert consts.distance is not None + consts.distance.clear() + assert consts.distance is None + + consts.update(distance={(0, 1): 1.0}) + consts.distance = None + assert consts.distance is None + + consts.update(distance={(0, 1): 1.0}) + assert len(consts.distance) == 1 + + consts.cartesian = [0, 1] + assert len(consts.cartesian) == 2 + consts.cartesian = None + assert consts.cartesian is None + + consts.update(cartesian=[0]) + assert len(consts.cartesian) == 1 + + +def test_copy(): + constraints = DistanceConstraints({(0, 1): 1.0}) + copied_constraints = constraints.copy() + constraints[(0, 1)] = 1.1 + + assert abs(copied_constraints[(0, 1)] - 1.0) < 1e-10 diff --git a/autodE/source/tests/test_examples.py b/autodE/source/tests/test_examples.py new file mode 100644 index 0000000000000000000000000000000000000000..811c184546614e483231502b40f798740fee1633 --- /dev/null +++ b/autodE/source/tests/test_examples.py @@ -0,0 +1,94 @@ +""" +Tests that are in the documentation. If they break the documentation is wrong! +If there is any change to the code please also change the examples to +accommodate the changes. +""" +from autode.species import Species +from autode.species import Molecule +from autode.config import Config +from autode.mol_graphs import split_mol_across_bond +from autode.atoms import Atom +import numpy as np +import os + +here = os.path.dirname(os.path.abspath(__file__)) + + +def test_species(): + species = Species(name="species", atoms=None, charge=0, mult=1) + assert species.n_atoms == 0 + + h2 = Species(name="H2", charge=0, mult=1, atoms=[Atom("H"), Atom("H")]) + assert h2.n_atoms == 2 + + # Expecting both atoms to be initialised at the origin + assert np.linalg.norm(h2.atoms[0].coord - h2.atoms[1].coord) < 1e-6 + + atom1, atom2 = h2.atoms + atom1.translate(vec=np.array([1.0, 0.0, 0.0])) + atom1.rotate(theta=np.pi, axis=np.array([0.0, 0.0, 1.0])) + + assert np.linalg.norm(atom1.coord - np.array([-1.0, 0.0, 0.0])) < 1e-6 + + assert h2.solvent is None + + f = Species( + name="F-", charge=-1, mult=1, atoms=[Atom("F")], solvent_name="DCM" + ) + assert f.solvent.g09 == "Dichloromethane" + assert f.solvent.xtb == "CH2Cl2" + + +def test_molecule(): + molecule = Molecule(name="molecule") + assert molecule.charge == 0 + assert molecule.mult == 1 + + water = Molecule(name="h2o", smiles="O") + assert water.n_atoms == 3 + assert all(node in water.graph.nodes for node in (0, 1, 2)) + assert (0, 1) in water.graph.edges + assert (0, 2) in water.graph.edges + + # Shift so the first atom is at the origin + water.translate(vec=-water.atoms[0].coord) + assert np.linalg.norm(water.atoms[0].coord - np.zeros(3)) < 1e-6 + + +def test_manipulation(): + methane = Molecule(name="CH4", smiles="C") + assert methane.n_atoms == 5 + ch3_nodes, h_nodes = split_mol_across_bond(methane.graph, bond=(0, 1)) + + ch3 = Molecule( + name="CH3", mult=2, atoms=[methane.atoms[i] for i in ch3_nodes] + ) + assert ch3.n_atoms == 4 + + h = Molecule(name="H", mult=2, atoms=[methane.atoms[i] for i in h_nodes]) + assert h.n_atoms == 1 + + +def test_conformers(tmpdir): + os.chdir(tmpdir) + + butane = Molecule(name="butane", smiles="CCCC") + butane.populate_conformers(n_confs=10) + + n_confs = len(butane.conformers) + assert n_confs > 1 + + # Lowing the RMSD threshold should afford more conformers + Config.rmsd_threshold = 0.01 + butane.populate_conformers(n_confs=10) + assert len(butane.conformers) > n_confs + + # Conformer generation should also work if the RDKit method fails + butane.rdkit_conf_gen_is_fine = False + butane.populate_conformers(n_confs=10) + assert len(butane.conformers) > 1 + + # Change RMSD threshold back + Config.rmsd_threshold = 0.3 + + os.chdir(here) diff --git a/autodE/source/tests/test_explicit_solvent.py b/autodE/source/tests/test_explicit_solvent.py new file mode 100644 index 0000000000000000000000000000000000000000..911956ab82944e00c892ba2c31dc1db485302741 --- /dev/null +++ b/autodE/source/tests/test_explicit_solvent.py @@ -0,0 +1,134 @@ +import pytest +import os +import numpy as np +from scipy.spatial import distance_matrix +from autode.solvent import ExplicitSolvent +from autode.species.molecule import Molecule +from autode.atoms import Atom + + +def methane_mol(): + return Molecule( + atoms=[ + Atom("C", 0.11105, -0.21307, 0.00000), + Atom("H", 1.18105, -0.21307, 0.00000), + Atom("H", -0.24562, -0.89375, 0.74456), + Atom("H", -0.24562, -0.51754, -0.96176), + Atom("H", -0.24562, 0.77207, 0.21720), + ] + ) + + +def water_mol(): + return Molecule( + atoms=[ + Atom("O", 1.64862, 0.46876, 0.00000), + Atom("H", 2.61862, 0.46876, 0.00000), + Atom("H", 1.32529, -0.28766, -0.51398), + ] + ) + + +def test_explicit_solvent_gen(): + mol = Molecule(smiles="C", solvent_name="water") + mol.explicitly_solvate(num=10) + assert mol.solvent.is_explicit + assert 75 < mol.solvent.dielectric < 80 + + mol.print_xyz_file(filename="tmp.xyz") + + solv_mol = Molecule("tmp.xyz") + assert solv_mol.n_atoms == (5 + 10 * 3) + # Solute should be first in the file + assert solv_mol.atoms[0].atomic_symbol == "C" + + coords = solv_mol.coordinates + solute_coords = coords[:5] + + for i in range(10): + solvent_mol_coords = coords[5 + i * 3 : 5 + (i + 1) * 3] + assert np.min(distance_matrix(solute_coords, solvent_mol_coords)) > 1.9 + + os.remove("tmp.xyz") + + mol.solvent = None + mol.explicitly_solvate(num=10, solvent="water") + assert mol.is_explicitly_solvated + + +def test_invalid_solvation(): + mol = Molecule(smiles="C", solvent_name="water") + + with pytest.raises(ValueError): + mol.explicitly_solvate(num=-1) + + with pytest.raises(ValueError): + mol.explicitly_solvate(num=0) + + solv = ExplicitSolvent(solute=mol, solvent=water_mol(), num=1) + + with pytest.raises(ValueError): + solv.solvent_atom_idxs(-1) # No solvent with index -1 + + with pytest.raises(ValueError): + solv.solvent_atom_idxs(1) # or with index 1 + + with pytest.raises(ValueError): + mol.explicitly_solvate(1, solvent=-1) + + gas_phase_mol = Molecule(smiles="C") + with pytest.raises(ValueError): + gas_phase_mol.explicitly_solvate(num=1) + + +def test_too_close_to_solute(): + solute = methane_mol() + water = water_mol() + + solv = ExplicitSolvent(solute=solute, solvent=water, num=1) + assert solv._too_close_to_solute( + water.coordinates, solute.coordinates, solute_radius=1.2 + ) + + +def test_too_close_to_solvent(): + solv = ExplicitSolvent(solute=methane_mol(), solvent=water_mol(), num=2) + assert solv.n_solvent_molecules == 2 + assert solv.solvent_n_atoms == 3 + + # Solvent coordinates + coords = np.array( + [ + [1.64862, 0.46876, 0.00000], + [2.61862, 0.46876, 0.00000], + [1.32529, -0.28766, -0.51398], + [1.69662, -0.04724, -1.83449], + [2.66662, -0.04724, -1.83449], + [1.37329, -0.88160, -1.46004], + ] + ) + + second_solv_idxs = solv.solvent_atom_idxs(1) + assert second_solv_idxs.tolist() == [3, 4, 5] + + assert solv._too_close_to_solvent( + coords, solvent_idxs=second_solv_idxs, max_idx=1 + ) + + +def test_equality(): + solv1 = ExplicitSolvent(solute=methane_mol(), solvent=water_mol(), num=2) + + solv2 = ExplicitSolvent(solute=methane_mol(), solvent=water_mol(), num=1) + + assert solv1 != 2 + assert solv1 != solv2 + assert solv1 == solv1 + + +def test_solvate_with_molecule(): + solute = methane_mol() + + # Solvent should be able to be any valid solvent molecule + solute.explicitly_solvate(solvent=methane_mol(), num=2) + assert solute.n_atoms + solute.solvent.n_atoms == 15 diff --git a/autodE/source/tests/test_g16.py b/autodE/source/tests/test_g16.py new file mode 100644 index 0000000000000000000000000000000000000000..8900d8c7950c3be1ef6e9b0a361a222882de1094 --- /dev/null +++ b/autodE/source/tests/test_g16.py @@ -0,0 +1,7 @@ +from autode.wrappers.G16 import G16 + + +def test_g16(): + # Identical to G09 so tests are implemented in G09 + g16 = G16() + assert g16.name == "g16" diff --git a/autodE/source/tests/test_geom.py b/autodE/source/tests/test_geom.py new file mode 100644 index 0000000000000000000000000000000000000000..64c85556ec5a43ebfadffe364114ee0b325ed560 --- /dev/null +++ b/autodE/source/tests/test_geom.py @@ -0,0 +1,95 @@ +import numpy as np +from autode import geom +from autode.atoms import Atom +import pytest + + +def test_are_coords_reasonable(): + good_coords = np.array([[0.0, 0.0, 0.0], [0.0, 0.0, 1.0]]) + assert geom.are_coords_reasonable(coords=good_coords) + + bad_coords1 = np.array([[0.0, 0.0, 0.0], [0.0, 0.0, 0.5]]) + assert not geom.are_coords_reasonable(coords=bad_coords1) + + +def test_points_on_sphere(): + points = geom.get_points_on_sphere(n_points=4) + + # 4 points on a sphere equally spaced should be roughly √2 apart + assert len(points) == 4 + assert np.abs(np.linalg.norm(points[0] - points[1]) - np.sqrt(2)) < 1e-6 + + points = geom.get_points_on_sphere(n_points=2) + # The algorithm isn't great at generated small numbers of points so 2 -> 3 + + # 3 points on a sphere equally spaced should be roughly the diameter + assert len(points) == 3 + assert np.abs(np.linalg.norm(points[0] - points[1]) - np.sqrt(3)) < 1e-6 + + +def test_calc_rmsd(): + atoms = [ + Atom("C", 0.0009, 0.0041, -0.0202), + Atom("H", -0.6577, -0.8481, -0.3214), + Atom("H", -0.4585, 0.9752, -0.3061), + Atom("H", 0.0853, -0.0253, 1.0804), + Atom("H", 1.0300, -0.1058, -0.4327), + ] + + atoms_rot = [ + Atom("C", -0.0009, -0.0041, -0.0202), + Atom("H", 0.6577, 0.8481, -0.3214), + Atom("H", 0.4585, -0.9752, -0.3061), + Atom("H", -0.0853, 0.0253, 1.0804), + Atom("H", -1.0300, 0.1058, -0.4327), + ] + + coords1 = np.array([atom.coord for atom in atoms]) + coords2 = np.array([atom.coord for atom in atoms_rot]) + + # Rotated coordinates should have almost 0 RMSD between them + assert geom.calc_rmsd(coords1, coords2) < 1e-5 + + # Coordinates need to have the same shape to calculate the RMSD + with pytest.raises(AssertionError): + _ = geom.calc_rmsd(coords1, coords2[1:]) + + assert geom.calc_heavy_atom_rmsd(atoms, atoms_rot) < 1e-5 + + # Permuting two hydrogens should generate a larger RMSD + atoms_rot[2], atoms_rot[3] = atoms_rot[3], atoms_rot[2] + rmsd = geom.calc_rmsd( + coords1=np.array([atom.coord for atom in atoms]), + coords2=np.array([atom.coord for atom in atoms_rot]), + ) + + assert rmsd > 0.1 + + # While the heavy atom RMSD should remain unchanged + assert geom.calc_heavy_atom_rmsd(atoms, atoms_rot) < 1e-6 + + +def test_symm_matrix_from_ltril(): + m = geom.symm_matrix_from_ltril(array=[0, 1, 2]) + + assert np.allclose(m, np.array([[0, 1], [1, 2]])) + + m = geom.symm_matrix_from_ltril(array=[0, -1, 4, 9, 0, 2]) + + assert np.allclose(m, np.array([[0, -1, 9], [-1, 4, 0], [9, 0, 2]])) + + with pytest.raises(ValueError): + _ = geom.symm_matrix_from_ltril(array=[1, 1]) + + +def test_gram_schmidt(): + """Test the projection function by performing a single GS iteration + see: https://en.wikipedia.org/wiki/Gram%E2%80%93Schmidt_process""" + + u1 = np.random.uniform(-1, 1, size=3) + + v2 = np.random.uniform(-1, 1, size=3) + u2 = v2 - geom.proj(u1, v2) + + # Resulting vectors should be orthogonal + assert np.isclose(np.dot(u1, u2), 0.0) diff --git a/autodE/source/tests/test_graphs.py b/autodE/source/tests/test_graphs.py new file mode 100644 index 0000000000000000000000000000000000000000..d82bf1a8f4bd37a232962305e9cfc4b63b54984d --- /dev/null +++ b/autodE/source/tests/test_graphs.py @@ -0,0 +1,393 @@ +from autode import mol_graphs +from autode.bond_rearrangement import BondRearrangement +from autode.exceptions import NoMolecularGraph, NoMapping +from autode.species.species import Species +from autode.species.molecule import Molecule +from autode.atoms import Atom +from autode.conformers import Conformer +from autode.input_output import xyz_file_to_atoms +from autode.config import Config +from . import testutils +import networkx as nx +import numpy as np +import pytest +import os +import platform + +here = os.path.dirname(os.path.abspath(__file__)) + +h_a = Atom(atomic_symbol="H", x=0.0, y=0.0, z=0.0) +h_b = Atom(atomic_symbol="H", x=0.0, y=0.0, z=0.7) + +h2 = Species(name="H2", atoms=[h_a, h_b], charge=0, mult=1) +mol_graphs.make_graph(h2) + +g = mol_graphs.MolecularGraph() +edges = [(0, 1), (1, 2), (2, 0), (0, 3), (3, 4)] +for edge in edges: + g.add_edge(*edge) + + +def test_graph_generation(): + assert h2.graph.number_of_edges() == 1 + assert h2.graph.number_of_nodes() == 2 + assert h2.graph.nodes[0]["atom_label"] == "H" + + assert "mol" in repr(h2.graph).lower() + + +def test_edge_cases(): + h_c = Atom(atomic_symbol="H", x=0.0, y=0.0, z=1.6) + + # For H3 with a slightly longer bond on one side there should only be + # 1 'bond' + h3 = Species(name="H2", atoms=[h_a, h_b, h_c], charge=0, mult=1) + mol_graphs.make_graph(h3) + + assert h3.graph.number_of_edges() == 1 + assert h3.graph.number_of_nodes() == 3 + + +def test_remove_bonds(): + b3h6 = Species( + name="diborane", + charge=0, + mult=1, + atoms=[ + Atom("B", -1.97106, 0.36170, -0.23984), + Atom("H", -0.91975, -0.06081, 0.43901), + Atom("H", -2.14001, -0.24547, -1.26544), + Atom("H", -2.99029, 0.31275, 0.39878), + Atom("B", -0.49819, 1.17500, 0.23984), + Atom("H", 0.52102, 1.22392, -0.39880), + Atom("H", -0.32919, 1.78217, 1.26543), + Atom("H", -1.54951, 1.59751, -0.43898), + ], + ) + + mol_graphs.make_graph(species=b3h6) + assert b3h6.graph.number_of_edges() == 6 + assert b3h6.graph.number_of_nodes() == 8 + + # Boron atoms should be 3 fold valent + assert len(list(b3h6.graph.neighbors(0))) == 3 + assert len(list(b3h6.graph.neighbors(4))) == 3 + + +def test_isomorphic_graphs(): + h2_alt = Species(name="H2", atoms=[h_b, h_a], charge=0, mult=1) + mol_graphs.make_graph(h2_alt) + + assert mol_graphs.is_isomorphic(h2.graph, h2_alt.graph) is True + + +def test_subgraph_isomorphism(): + h_c = Atom(atomic_symbol="H", x=0.0, y=0.0, z=1.4) + h_d = Atom(atomic_symbol="H", x=0.0, y=0.0, z=2.1) + h4 = Species(name="H4", atoms=[h_a, h_b, h_c, h_d], charge=0, mult=1) + mol_graphs.make_graph(h4) + + assert ( + mol_graphs.is_subgraph_isomorphic( + larger_graph=h4.graph, smaller_graph=h2.graph + ) + is True + ) + + # H3 in a triangular arrangement should not be sub-graph isomorphic to linear H4 + h_e = Atom(atomic_symbol="H", x=0.3, y=0.0, z=0.3) + h3 = Species(name="H_H", charge=0, mult=1, atoms=[h_a, h_b, h_e]) + mol_graphs.make_graph(h3, allow_invalid_valancies=True) + assert ( + mol_graphs.is_subgraph_isomorphic( + larger_graph=h4.graph, smaller_graph=h3.graph + ) + is False + ) + + +def test_ts_template(): + h_c = Atom(atomic_symbol="H", x=0.0, y=0.0, z=1.4) + + ts_template = Species( + name="template", charge=0, mult=1, atoms=[h_a, h_b, h_c] + ) + mol_graphs.make_graph(species=ts_template, allow_invalid_valancies=True) + ts_template.graph.edges[0, 1]["active"] = True + + ts = Species(name="template", charge=0, mult=1, atoms=[h_a, h_b, h_c]) + mol_graphs.make_graph(species=ts, allow_invalid_valancies=True) + ts.graph.edges[1, 2]["active"] = True + + mapping = mol_graphs.get_mapping_ts_template(ts.graph, ts_template.graph) + assert mapping is not None + assert type(mapping) == dict + + assert mol_graphs.is_isomorphic( + ts.graph, ts_template.graph, ignore_active_bonds=True + ) + + +def test_truncated_active_graph(): + h_c = Atom(atomic_symbol="H", x=0.0, y=0.0, z=1.4) + h_d = Atom(atomic_symbol="H", x=0.0, y=0.0, z=2.1) + + ts = Species(name="template", charge=0, mult=1, atoms=[h_a, h_b, h_c, h_d]) + mol_graphs.make_graph(species=ts, allow_invalid_valancies=True) + + # H--active--H--H--H should truncate by keeping only the nearest neighbours to the first two atoms + truncated_graph = mol_graphs.get_truncated_active_mol_graph( + ts.graph, active_bonds=[(0, 1)] + ) + assert truncated_graph.number_of_nodes() == 3 + assert truncated_graph.number_of_edges() == 2 + + +def test_mapping(): + h_c = Atom(atomic_symbol="H", x=0.0, y=0.0, z=1.4) + + h3_a = Species(name="template", charge=0, mult=1, atoms=[h_a, h_b, h_c]) + mol_graphs.make_graph(species=h3_a, allow_invalid_valancies=True) + + h3_b = Species(name="template", charge=0, mult=1, atoms=[h_a, h_b, h_c]) + mol_graphs.make_graph(species=h3_b, allow_invalid_valancies=True) + + # Isomorphic (identical) graphs should have at least one mapping between them + mapping = mol_graphs.get_mapping(h3_b.graph, h3_a.graph) + assert mapping is not None + assert type(mapping) == dict + + +def test_not_isomorphic(): + h_c = Atom(atomic_symbol="H", x=0.0, y=0.0, z=1.0) + h2_b = Species(name="template", charge=0, mult=1, atoms=[h_a, h_c]) + mol_graphs.make_graph(species=h2_b, rel_tolerance=0.3) + + assert mol_graphs.is_isomorphic(h2.graph, h2_b.graph) is False + + +def test_not_isomorphic2(): + c = Atom(atomic_symbol="C", x=0.0, y=0.0, z=0.7) + ch = Species(name="ch", atoms=[h_a, c], charge=0, mult=2) + mol_graphs.make_graph(ch) + + assert mol_graphs.is_isomorphic(h2.graph, ch.graph) is False + + +def test_find_cycles(): + assert mol_graphs.find_cycles(g) == [[1, 2, 0]] + + +def test_reac_to_prods(): + rearrang = BondRearrangement([(0, 4)], [(3, 4)]) + prod_graph = mol_graphs.reac_graph_to_prod_graph(g, rearrang) + expected_edges = [(0, 1), (1, 2), (2, 0), (0, 3), (0, 4)] + expected_graph = mol_graphs.MolecularGraph() + for edge in expected_edges: + expected_graph.add_edge(*edge) + + assert mol_graphs.is_isomorphic(expected_graph, prod_graph) + + +def test_split_graph(): + assert mol_graphs.split_mol_across_bond(g, bond=(0, 3)) == [ + [0, 1, 2], + [3, 4], + ] + + +def test_set_pi_bonds(): + ethene = Species( + name="ethene", + charge=0, + mult=1, + atoms=[ + Atom("C", -2.20421, 0.40461, 0.00000), + Atom("C", -0.87115, 0.38845, 0.00000), + Atom("H", -2.76098, -0.22576, 0.68554), + Atom("H", -2.74554, 1.04829, -0.68554), + Atom("H", -0.32982, -0.25523, 0.68554), + Atom("H", -0.31437, 1.01882, -0.68554), + ], + ) + mol_graphs.make_graph(ethene) + + assert ethene.graph.edges[0, 1]["pi"] is True + assert ethene.graph.edges[1, 0]["pi"] is True + assert ethene.graph.edges[0, 2]["pi"] is False + + acetylene = Species( + name="acetylene", + charge=0, + mult=1, + atoms=[ + Atom("C", -2.14031, 0.40384, 0.00000), + Atom("C", -0.93505, 0.38923, 0.00000), + Atom("H", -3.19861, 0.41666, 0.00000), + Atom("H", 0.12326, 0.37640, 0.00000), + ], + ) + mol_graphs.make_graph(acetylene) + + assert acetylene.graph.edges[0, 1]["pi"] is True + assert acetylene.graph.edges[0, 2]["pi"] is False + + +def test_species_isomorphism(): + h2_copy = Species(name="H2", atoms=[h_a, h_b], charge=0, mult=1) + assert mol_graphs.species_are_isomorphic(h2, h2_copy) + + # Shift one of the atoms far away and remake the graph + h2_copy.atoms[1].translate(vec=np.array([10, 0, 0])) + mol_graphs.make_graph(h2_copy) + + assert mol_graphs.species_are_isomorphic(h2, h2_copy) is False + + # Generating a pair of conformers that are isomporhpic should return that + # the species are again isomorphic + h2.conformers = [ + Conformer(name="h2_conf", atoms=[h_a, h_b], charge=0, mult=1) + ] + h2_copy.conformers = [ + Conformer(name="h2_conf", atoms=[h_a, h_b], charge=0, mult=1) + ] + + assert mol_graphs.species_are_isomorphic(h2, h2_copy) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "e2_tss.zip")) +def test_isomorphic_no_active(): + ts_syn = Conformer( + name="syn_ts", + charge=-1, + mult=0, + atoms=xyz_file_to_atoms("E2_ts_syn.xyz"), + ) + mol_graphs.make_graph(ts_syn) + for pair in [(8, 5), (0, 5), (1, 2)]: + ts_syn.graph.add_active_edge(*pair) + + ts_anti = Conformer( + name="anti_ts", charge=-1, mult=0, atoms=xyz_file_to_atoms("E2_ts.xyz") + ) + mol_graphs.make_graph(ts_anti) + + assert mol_graphs.is_isomorphic( + ts_syn.graph, ts_anti.graph, ignore_active_bonds=True + ) + + +def test_timeout(): + if platform.system() == "Windows": + Config.use_experimental_timeout = True + + # Generate a large-ish graph + graph = mol_graphs.MolecularGraph() + for i in range(10000): + graph.add_node(i) + + for _ in range(5000): + (i, j) = np.random.randint(0, 1000, size=2) + + if (i, j) not in graph.edges: + graph.add_edge(i, j) + + node_perm = np.random.permutation(list(graph.nodes)) + mapping = {u: v for (u, v) in zip(graph.nodes, node_perm)} + + isomorphic_graph = nx.relabel_nodes(graph, mapping=mapping, copy=True) + + # With a short timeout this should return False - not sure this is the + # optimal behavior + assert not mol_graphs.is_isomorphic(graph, isomorphic_graph) + + if platform.system() == "Windows": + Config.use_experimental_timeout = False + + +def test_species_conformers_isomorphic(): + h2_a = Molecule(name="H2", atoms=[Atom("H"), Atom("H", x=0.7)]) + + h2_b = Molecule(name="H2", atoms=[Atom("H"), Atom("H", x=1.5)]) + + assert not mol_graphs.species_are_isomorphic(h2_a, h2_b) + + # Should raise an exception for two non-isomorphic graphs + with pytest.raises(NoMapping): + mol_graphs.get_mapping(h2_a.graph, h2_b.graph) + + h2_a.conformers = None + h2_b.conformers = [ + Conformer(name="H2", atoms=[Atom("H"), Atom("H", x=0.7)]) + ] + + assert mol_graphs.species_are_isomorphic(h2_a, h2_b) + + +def test_graph_without_active_edges(): + mol = Molecule(name="H2", atoms=[Atom("H"), Atom("H", x=0.7)]) + mol.graph.edges[(0, 1)]["active"] = True + + graph = mol_graphs.get_graph_no_active_edges(mol.graph) + # Should now have no edges if the one bond was defined as active + assert graph.number_of_edges() == 0 + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "complex_geoms.zip")) +def test_not_isomorphic_metal_complexes(): + ene = Species( + name="ene", charge=0, mult=1, atoms=xyz_file_to_atoms("co_ene.xyz") + ) + mol_graphs.make_graph(ene) + + alkyl = Species( + name="akyl", charge=0, mult=1, atoms=xyz_file_to_atoms("co_akyl.xyz") + ) + mol_graphs.make_graph(alkyl) + + assert not mol_graphs.is_isomorphic(ene.graph, alkyl.graph) + + +def test_remove_invalid(): + pd_ph3_mei = Molecule( + name="PdPH3MeI", + charge=0, + mult=1, + atoms=[ + Atom("H", 1.80869, 1.12629, -1.71394), + Atom("P", 0.93049, 0.14302, -1.18126), + Atom("H", 0.08882, 0.04813, -2.32424), + Atom("H", 0.10295, 1.04171, -0.45224), + Atom("Pd", 1.84406, -1.75308, -0.34018), + Atom("I", 4.42392, -3.02167, -1.27125), + Atom("C", 2.58481, -3.56837, 0.47849), + Atom("C", 1.98665, -4.84613, -0.04152), + Atom("H", 1.81235, -2.93290, 1.06885), + Atom("H", 3.42334, -3.70664, 1.16736), + Atom("H", 1.26274, -4.66434, -0.84864), + Atom("H", 1.44201, -5.34965, 0.77827), + Atom("H", 2.76011, -5.52942, -0.41773), + ], + ) + adj_matrix = pd_ph3_mei.bond_matrix + + assert adj_matrix[6, 9] + assert not adj_matrix[5, 9] + assert not adj_matrix[9, 5] + + +def test_expected_planar_geometry(): + methane = Molecule(smiles="C") + assert methane.has_reasonable_coordinates + + # Methane is not expected to have a planar geometry + assert not methane.graph.expected_planar_geometry + + +def test_graph_active_bonds_property(): + assert len(h2.graph.active_bonds) == 0 + + tmp_h2 = h2.copy() + tmp_h2.graph.add_active_edge(0, 1) + + assert tmp_h2.graph.active_bonds == [(0, 1)] diff --git a/autodE/source/tests/test_hessian.py b/autodE/source/tests/test_hessian.py new file mode 100644 index 0000000000000000000000000000000000000000..1992372eb5ba36fa237c83acb0e46bc75e8beefb --- /dev/null +++ b/autodE/source/tests/test_hessian.py @@ -0,0 +1,989 @@ +import os +import pytest +import pickle +import numpy as np +import autode as ade +from scipy.stats import special_ortho_group +from autode.utils import work_in_tmp_dir, ProcessPool +from . import testutils +import multiprocessing as mp +from autode.config import Config +from autode.atoms import Atom, Atoms +from autode.methods import ORCA, XTB +from autode.calculations import Calculation +from autode.species import Molecule +from autode.values import Frequency +from autode.geom import calc_rmsd +from autode.units import wavenumber, ha_per_ang_sq +from autode.exceptions import CalculationException +from autode.wrappers.keywords import pbe0 +from autode.transition_states.base import displaced_species_along_mode +from autode.values import Distance +from autode.wrappers.keywords import HessianKeywords, GradientKeywords +from autode.hessians import ( + Hessian, + NumericalHessianCalculator, + HybridHessianCalculator, +) + +here = os.path.dirname(os.path.abspath(__file__)) +Config.freq_scale_factor = 1.0 + +# Ha/Å-2 +h2o_hessian_arr = np.array( + [ + [ + 2.31423829e00, + 1.56166837e-02, + 8.61890193e-09, + -1.16433138e00, + -7.61763557e-01, + -1.09191486e-09, + -1.14970123e00, + 7.46143320e-01, + -7.39260002e-09, + ], + [ + 1.56179128e-02, + 1.27705582e00, + -6.14958440e-09, + -5.68563567e-01, + -6.43164982e-01, + -1.65317527e-10, + 5.52895134e-01, + -6.33819376e-01, + 6.08197843e-09, + ], + [ + 8.56473769e-09, + -6.20522983e-09, + 2.38461667e-02, + -2.33540672e-09, + 1.74823515e-10, + -9.94345634e-03, + -6.14782246e-09, + 6.09452763e-09, + -1.39291346e-02, + ], + [ + -1.16340880e00, + -5.68004778e-01, + -2.18254923e-09, + 1.21307403e00, + 6.64576848e-01, + 1.49260050e-09, + -4.97333957e-02, + -9.65238352e-02, + 7.74806492e-10, + ], + [ + -7.61157775e-01, + -6.42928219e-01, + 1.90093517e-10, + 6.64585946e-01, + 6.09644197e-01, + 8.72800888e-10, + 9.65585874e-02, + 3.32486321e-02, + -1.08771472e-09, + ], + [ + -8.90246292e-10, + -1.06078463e-10, + -1.02164725e-02, + 1.50223901e-09, + 8.69409685e-10, + 4.09774275e-02, + -7.02197099e-10, + -8.40313571e-10, + -3.07468472e-02, + ], + [ + -1.14872616e00, + 5.52306917e-01, + -6.27166936e-09, + -4.97336713e-02, + 9.66013927e-02, + -8.09399241e-10, + 1.19832228e00, + -6.48950952e-01, + 6.85905861e-09, + ], + [ + 7.45507209e-01, + -6.33561850e-01, + 6.12671503e-09, + -9.64812852e-02, + 3.32487080e-02, + -7.74615353e-10, + -6.48960123e-01, + 6.00277064e-01, + -5.08741780e-09, + ], + [ + -7.51451327e-09, + 6.14650239e-09, + -1.41604277e-02, + 6.62204039e-10, + -1.02974704e-09, + -3.07470051e-02, + 6.85803822e-09, + -5.09659842e-09, + 4.49197416e-02, + ], + ] +) + +# Ha/a0^2 +co2_hessian_arr = np.array( + [ + [ + 1.1314383525e00, + 4.2385767412e-04, + 3.5051771425e-04, + -1.0501086627e00, + -3.7813825173e-04, + -3.4457384398e-04, + -8.1229733239e-02, + -3.7456312285e-05, + -6.5999542510e-05, + ], + [ + 4.2325160632e-04, + 3.6570663096e-02, + 1.2516781525e-07, + -3.8221942577e-04, + -7.3247574779e-02, + -1.2460642412e-07, + -4.1000250417e-05, + 3.6660190261e-02, + -2.4295086828e-08, + ], + [ + 3.4996749671e-04, + 1.2517409883e-07, + 3.6556726341e-02, + -2.8455375094e-04, + -9.9360220558e-08, + -7.3235662640e-02, + -6.5386128105e-05, + -2.2911454518e-08, + 3.6651071695e-02, + ], + [ + -1.0457642155e00, + -3.8238813778e-04, + -2.8469599658e-04, + 2.0913922620e00, + 7.8828445803e-04, + 6.8225384914e-04, + -1.0458280713e00, + -4.2242186106e-04, + -2.7748424189e-04, + ], + [ + -3.7766992874e-04, + -7.3411408385e-02, + -1.0641198331e-07, + 7.8820556787e-04, + 1.4684935286e-01, + 2.6867393467e-07, + -4.1059908388e-04, + -7.3404560780e-02, + -1.1443188980e-07, + ], + [ + -3.4413274803e-04, + -1.3166345414e-07, + -7.3399496875e-02, + 6.8218586806e-04, + 2.6867411699e-07, + 1.4684780089e-01, + -3.3810770450e-04, + -1.4275024213e-07, + -7.3392638046e-02, + ], + [ + -8.1229732348e-02, + -4.1002276652e-05, + -6.5388156506e-05, + -1.0501723903e00, + -4.1114029467e-04, + -3.3853608979e-04, + 1.1315021908e00, + 4.6040543323e-04, + 3.4390672878e-04, + ], + [ + -3.7453739804e-05, + 3.6660190178e-02, + -2.2915437653e-08, + -4.2226004550e-04, + -7.3240736078e-02, + -1.3510830784e-07, + 4.5974573122e-04, + 3.6563884025e-02, + 1.3393048024e-07, + ], + [ + -6.5997594969e-05, + -2.4306709511e-08, + 3.6651071540e-02, + -2.7734157097e-04, + -1.0679186055e-07, + -7.3228811671e-02, + 3.4336655361e-04, + 1.3393871147e-02, + 3.6549939293e-02, + ], + ] +) + + +def assert_correct_co2_frequencies(hessian, expected=(666, 1415, 2517)): + """Ensure the projected frequencies of CO2 are roughly right""" + nu_1, nu_2, nu_3 = expected + + print(hessian.frequencies_proj) + assert sum(freq == 0.0 for freq in hessian.frequencies_proj) == 5 + + # Should have a degenerate bending mode for CO2 with ν = 666 cm-1 + assert ( + sum( + np.isclose(Frequency(nu_1, units="cm-1"), freq, atol=2.0) + for freq in hessian.frequencies_proj + ) + == 2 + ) + + # and two others that are larger + assert ( + sum( + np.isclose(Frequency(nu_2, units="cm-1"), freq, atol=2.0) + for freq in hessian.frequencies_proj + ) + == 1 + ) + + assert ( + sum( + np.isclose(Frequency(nu_3, units="cm-1"), freq, atol=2.0) + for freq in hessian.frequencies_proj + ) + == 1 + ) + + +def test_hessian_class(): + hessian = Hessian(h2o_hessian_arr, units="Ha Å^-2") + assert "hessian" in repr(hessian).lower() + assert hash(hessian) is not None + + # Cannot project without atoms + with pytest.raises(ValueError): + _ = hessian.frequencies_proj + + with pytest.raises(ValueError): + _ = hessian.normal_modes_proj + + with pytest.raises(ValueError): + _ = hessian._proj_matrix + + with pytest.raises(ValueError): + _ = hessian._mass_weighted + + # without atoms the number of translations/rotations/vibrations is unknown + with pytest.raises(ValueError): + assert hessian.n_tr == 6 + + with pytest.raises(ValueError): + assert hessian.n_v == 3 + + # Must have matching Hessian and atom dimensions i.e. 3Nx3N for N atoms + with pytest.raises(ValueError): + _ = Hessian(h2o_hessian_arr, atoms=[]) + + # Check the number of translations and rotations and the number of + # expected vibrations + hessian.atoms = Atoms( + [ + Atom("O", -0.0011, 0.3631, -0.0), + Atom("H", -0.8250, -0.1819, -0.0), + Atom("H", 0.8261, -0.1812, 0.0), + ] + ) + + assert hessian.n_tr == 6 + assert hessian.n_v == 3 + + +def test_hessian_set(): + h2o = Molecule(smiles="O") + + # Cannot set the Hessian as a matrix that isn't 3Nx3N + with pytest.raises(ValueError): + h2o.hessian = np.array([]) + + with pytest.raises(ValueError): + h2o.hessian = np.arange(0, 3 * h2o.n_atoms) + + # Hessian must be an array.. + with pytest.raises(ValueError): + h2o.hessian = 5 + + assert h2o.hessian is None + h2o.hessian = np.zeros(shape=(3 * h2o.n_atoms, 3 * h2o.n_atoms)) + assert h2o.hessian is not None + + +def test_hessian_freqs(): + h2o = Molecule(smiles="O") + h2o.hessian = h2o_hessian_arr + + assert isinstance(h2o.hessian, Hessian) + freqs = h2o.hessian.frequencies + + # Should have 2 frequencies in the 3500 cm-1 range for the O-H stretches + assert ( + sum( + [ + Frequency(3000, units=wavenumber) + < freq + < Frequency(4000, units=wavenumber) + for freq in freqs + ] + ) + == 2 + ) + + # without projection there is an imaginary frequency + assert sum(freq.is_imaginary for freq in freqs) == 1 + + assert h2o.hessian.normal_modes[0].shape == (h2o.n_atoms, 3) + + # Projecting should give frequencies close to those obtained from ORCA + # the vibrational frequencies are the largest three (non-zero) + nu_1, nu_2, nu_3 = h2o.hessian.frequencies_proj[-3:] + assert np.isclose(nu_1, 1567.610851, atol=1.0) + assert np.isclose(nu_2, 3467.698182, atol=1.0) + assert np.isclose(nu_3, 3651.462209, atol=1.0) + + +def test_hessian_scaled_freqs(): + h2o = Molecule(smiles="O") + h2o.hessian = h2o_hessian_arr + + nu_no_scaling = h2o.hessian.frequencies_proj[-1] + + Config.freq_scale_factor = 0.9 + h2o.hessian = h2o_hessian_arr + + assert np.isclose( + 0.9 * nu_no_scaling, h2o.hessian.frequencies_proj[-1], atol=0.1 + ) + + Config.freq_scale_factor = None + + +def test_hessian_scale_factor(): + Config.freq_scale_factor = None # Unset.. + + h2o = Molecule(smiles="O") + hessian = Hessian(h2o_hessian_arr, atoms=h2o.atoms, functional=pbe0) + + # 0.96 is the appropriate scale factor for PBE0, also known as PBE1PBE in + # Gaussian + assert np.isclose(hessian._freq_scale_factor, 0.96) + + Config.freq_scale_factor = 0.9 + assert np.isclose(hessian._freq_scale_factor, 0.9) + + Config.freq_scale_factor = 1.0 + hessian.functional = None + assert np.isclose(hessian._freq_scale_factor, 1.0) + + Config.freq_scale_factor = None + hessian.functional = None + assert np.isclose(hessian._freq_scale_factor, 1.0) + + Config.freq_scale_factor = 1.0 + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "hessians.zip")) +def test_hessian_modes(): + """Ensure the translational, rotational and vibrational modes are close + to the expected values for a projected Hessian""" + + h2o = Molecule("H2O_hess_orca.xyz") + h2o.hessian = h2o_hessian_arr + assert h2o.hessian.units == ha_per_ang_sq + + # The structure is a minimum, thus there should be no imaginary frequencies + assert h2o.imaginary_frequencies is None + + for trans_mode in h2o.hessian.normal_modes_proj[:3]: + assert np.allclose(trans_mode, np.zeros(shape=(h2o.n_atoms, 3))) + + for rot_mode in h2o.hessian.normal_modes_proj[3:6]: + assert np.allclose(rot_mode, np.zeros(shape=(h2o.n_atoms, 3))) + + for i, vib_mode in enumerate(h2o.hessian.normal_modes_proj[6:]): + # Vibrational modes should have no component in the z-axis + for j, _ in enumerate(h2o.atoms): + assert np.isclose(vib_mode[j, 2], 0.0, atol=1e-4) + + # and be close to their un-projected analogues for a minimum either + # forwards or backwards (projection doesn't conserve the direction) + assert np.allclose( + vib_mode, h2o.hessian.normal_modes[6 + i], atol=0.1 + ) or np.allclose(vib_mode, -h2o.hessian.normal_modes[6 + i], atol=0.1) + + # Hessian units should be retained + assert h2o.hessian.units == ha_per_ang_sq + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "hessians.zip")) +def test_proj_modes(): + """ + Test the projected normal modes are close to those obtained from an + ORCA projection. Displaced geometries generated with Chemcraft using a + scale factor of 0.5 + """ + bend_orca = np.array( + [ + [-0.001006000, 0.326448000, 0.0], + [-1.025049000, 0.108976000, 0.0], + [1.024653500, 0.109669000, 0.0], + ] + ) + + symm_orca = np.array( + [ + [0.001894000, 0.340046000, 0.00], + [-0.547993500, -0.014124500, 0.00], + [0.501572000, 0.016941000, 0.00], + ] + ) + + asym_orca = np.array( + [ + [0.035851500, 0.365107500, 0.0], + [-1.143189500, -0.391339500, 0.0], + [0.557796500, -0.003620500, 0.0], + ] + ) + + h2o = Molecule("H2O_hess_orca.xyz") + h2o.hessian = h2o_hessian_arr + + for mode_n, coords in zip((6, 7, 8), (bend_orca, symm_orca, asym_orca)): + bend_f = displaced_species_along_mode( + h2o, mode_number=mode_n, disp_factor=0.5 + ) + + bend_b = displaced_species_along_mode( + h2o, mode_number=mode_n, disp_factor=-0.5 + ) + + # Correct displacement could be either forwards or backwards + assert ( + calc_rmsd(coords, bend_f.coordinates) < 0.03 + or calc_rmsd(coords, bend_b.coordinates) < 0.03 + ) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "hessians.zip")) +def test_hessian_linear_freqs(): + co2 = Molecule("CO2_opt.xyz") + assert co2.is_linear() + + co2.hessian = Hessian(co2_hessian_arr, units="Ha/a0^2") + assert_correct_co2_frequencies(hessian=co2.hessian) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "hessians.zip")) +def test_gaussian_hessian_extract_h2(): + h2 = ade.Molecule( + atoms=[ade.Atom("H", x=0.3804), ade.Atom("H", x=-0.3804)] + ) + + calc = Calculation( + name="tmp", + molecule=h2, + method=ade.methods.G09(), + keywords=ade.HessianKeywords(), + ) + + calc.set_output_filename("H2_hess_g09.log") + assert np.isclose( + h2.hessian.frequencies[-1], Frequency(4383.9811), atol=1.0 + ) + + assert np.isclose( + h2.hessian.frequencies_proj[-1], Frequency(4383.9811), atol=1.0 + ) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "hessians.zip")) +def test_gaussian_hessian_extract_co2(): + co2 = Molecule("CO2_opt.xyz") + # Set to match the input orientation of the opt/hessian calculation below + co2.atoms = Atoms( + [ + Atom("O", 0.000000, 0.000000, 1.159304), + Atom("C", 0.000000, 0.000000, -0.000003), + Atom("O", 0.000000, 0.000000, -1.159301), + ] + ) + + calc = Calculation( + name="tmp", + molecule=co2, + method=ade.methods.G09(), + keywords=ade.HessianKeywords([]), + ) + + calc.set_output_filename("CO2_opt_hess_g09.log") + + assert all( + np.isclose(freq, Frequency(0, units="cm-1"), atol=10) + for freq in co2.hessian.frequencies[:5] + ) + + assert all(freq == 0.0 for freq in co2.hessian.frequencies_proj[:5]) + + assert_correct_co2_frequencies(hessian=co2.hessian) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "hessians.zip")) +def test_nwchem_hessian_extract_h2o(): + water = ade.Molecule(smiles="O") + calc = Calculation( + name="tmp", + molecule=water, + method=ade.methods.NWChem(), + keywords=ade.HessianKeywords(), + ) + + calc.set_output_filename("H2O_hess_nwchem.out") + hessian = water.hessian + + for freqs in (hessian.frequencies, hessian.frequencies_proj): + assert sum(np.isclose(freq, 0.0, atol=15) for freq in freqs) == 6 + + assert ( + sum(np.isclose(freq, Frequency(1642.78), atol=4) for freq in freqs) + == 1 + ) + + assert ( + sum(np.isclose(freq, Frequency(3860.38), atol=4) for freq in freqs) + == 1 + ) + + assert ( + sum(np.isclose(freq, Frequency(3959.20), atol=4) for freq in freqs) + == 1 + ) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "hessians.zip")) +def test_nwchem_hessian_co2(): + co2 = ade.Molecule(smiles="O=C=O") + calc = Calculation( + name="tmp", + molecule=co2, + method=ade.methods.NWChem(), + keywords=ade.HessianKeywords(), + ) + calc.set_output_filename("CO2_hess_nwchem.out") + assert_correct_co2_frequencies( + hessian=co2.hessian, expected=(659.76, 1406.83, 2495.73) + ) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "hessians.zip")) +def test_sn2_imag_mode(): + """ + Ensure the imaginary mode for an SN2 reaction is close to that obtained + from ORCA by checking forwards (f) and backwards (b) displaced geometries + using a factor of 0.5 + """ + + ts = Molecule("sn2_TS.xyz", charge=-1) + + calc = Calculation( + name="tmp", molecule=ts, method=ORCA(), keywords=ORCA().keywords.hess + ) + + calc.set_output_filename("sn2_TS.out") + + assert np.isclose( + ts.imaginary_frequencies[0], Frequency(-552.64, units="cm-1"), atol=1.0 + ) + + imag_f = displaced_species_along_mode(ts, mode_number=6, disp_factor=0.5) + imag_f_ade = imag_f.coordinates + imag_b = displaced_species_along_mode(ts, mode_number=6, disp_factor=-0.5) + imag_b_ade = imag_b.coordinates + + imag_f_orca = np.array( + [ + [-5.081783000, 4.433438000, 0.062274000], + [-0.900674000, 4.531897500, -0.036945500], + [-3.619944500, 4.467834500, 0.028735000], + [-3.159098000, 3.868992500, -0.899404000], + [-3.116380000, 3.989669000, 1.004047500], + [-3.177138500, 5.577882000, -0.051681500], + ] + ) + + imag_b_orca = np.array( + [ + [-5.348013000, 4.42739200, 0.06913800], + [-1.035348000, 4.52875050, -0.03346050], + [-2.747891500, 4.48803350, 0.00668900], + [-3.369132000, 3.90162150, -0.83858800], + [-3.330032000, 4.01514900, 0.94577250], + [-3.390143500, 5.50372200, -0.04345650], + ] + ) + + assert ( + calc_rmsd(imag_f_ade, imag_f_orca) < 0.05 + and calc_rmsd(imag_b_ade, imag_b_orca) < 0.05 + ) or ( + calc_rmsd(imag_b_ade, imag_f_orca) < 0.05 + and calc_rmsd(imag_f_ade, imag_b_orca) < 0.05 + ) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "hessians.zip")) +def test_extract_wrong_molecule_hessian(): + calc = Calculation( + name="tmp", + molecule=ade.Molecule(smiles="[H]"), + method=ade.methods.G09(), + keywords=ade.HessianKeywords([]), + ) + + # Should raise an exception if the Hessian extracted is not 3Nx3N for + # N atoms (1 here) + with pytest.raises(CalculationException): + calc.set_output_filename("CO2_opt_hess_g09.log") + + +def test_num_hess_invalid_input(): + water = Molecule(smiles="O") + orca = ORCA() + + # Keywords must be GradientKeywords that don't include any 'Hessian' + # or 'frequency' keywords + for invalid_kwds in ( + None, + GradientKeywords(["Freq", "PBE", "Def2-SVP"]), + HessianKeywords(["PBE", "Def2-SVP"]), + ): + with pytest.raises(ValueError): + nhc = NumericalHessianCalculator( + species=water, + method=orca, + keywords=invalid_kwds, + do_c_diff=False, + shift=Distance(1e-3, units="Å"), + ) + nhc.calculate() + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "num_hess.zip")) +def test_h2_hessian(): + h2 = Molecule(name="H2", atoms=[Atom("H"), Atom("H", x=0.77)]) + + h2.calc_hessian(method=ORCA(), numerical=False) + analytic_hessian = h2.hessian.copy() + + h2.hessian = None # Clear the analytic Hessian + + h2.calc_hessian(method=ORCA(), numerical=True) + assert h2.hessian is not None + + num_hessian = h2.hessian.copy() + + assert np.allclose(analytic_hessian, num_hessian, atol=1e-2) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "num_hess.zip")) +def test_h2_c_diff_hessian(): + h2 = Molecule(name="H2", atoms=[Atom("H"), Atom("H", x=0.77)]) + + h2.calc_hessian(method=ORCA(), numerical=False) + analytic_hessian = h2.hessian.copy() + + h2.hessian = None # Clear the analytic Hessian and calculate a numerical + h2.calc_hessian( + method=ORCA(), numerical=True, use_central_differences=True + ) + + # Central differences should afford a very good Hessian cf. analytic + assert np.allclose(analytic_hessian, h2.hessian, atol=1e-3) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "num_hess.zip")) +@testutils.requires_working_xtb_install +def test_h2_xtb_vs_orca_hessian(): + h2 = Molecule(name="H2", atoms=[Atom("H"), Atom("H", x=0.77)]) + + h2.calc_hessian(method=ORCA(), numerical=False) + orca_hessian = h2.hessian.copy() + + h2.calc_hessian(method=XTB(), numerical=True, use_central_differences=True) + xtb_hessian = h2.hessian.copy() + + # ORCA and XTB Hessians should be similar, ish + assert np.allclose(orca_hessian, xtb_hessian, atol=0.3) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "num_hess.zip")) +@testutils.requires_working_xtb_install +def test_ind_num_hess_row(): + """Calculate d^2E/dx0dx0 using numerical displacements with and + without central differences""" + + h2 = Molecule(name="H2", atoms=[Atom("H"), Atom("H", x=0.77)]) + xtb = XTB() + + for flag in (True, False): + calculator = NumericalHessianCalculator( + species=h2, + method=xtb, + keywords=xtb.keywords.grad, + do_c_diff=flag, + shift=Distance(0.001, units="Å"), + ) + + # Non central differences require an initial gradient at the curr geom + calculator._init_gradient = calculator._gradient(calculator._species) + + if flag: + row = calculator._cdiff_row(atom_idx=0, component=0) + else: + row = calculator._diff_row(atom_idx=0, component=0) + + assert np.isclose(row[0], 1.00957, atol=1e-1) + + +def test_partial_num_hess_init(): + # Cannot generate a PartialNumericalHessianCalculator with atom indexes + # that are not present in the system + mol = ade.Molecule(smiles="O") + + orca = ORCA() + orca.path = here # spoof ORCA install + assert orca.is_available + + for invalid_idx in (-1, 3, "a"): + with pytest.raises(ValueError): + _ = HybridHessianCalculator( + mol, + idxs=(invalid_idx,), + shift=Distance(0.01), + lmethod=orca, + hmethod=orca, + ) + + +@testutils.requires_working_xtb_install +@testutils.work_in_zipped_dir(os.path.join(here, "data", "num_hess.zip")) +def test_partial_water_num_hess(): + orca_num_hess = np.array( + [ + [2.31, 0.01, 0.0, -1.16, -0.76, 0.0, -1.15, 0.75, -0.0], + [0.01, 1.28, 0.0, -0.57, -0.64, 0.0, 0.55, -0.63, 0.0], + [0.0, 0.0, 0.03, -0.0, -0.0, -0.01, 0.0, -0.0, -0.02], + [-1.16, -0.57, -0.0, 1.22, 0.67, -0.0, -0.05, -0.1, 0.0], + [-0.76, -0.64, -0.0, 0.67, 0.61, -0.0, 0.1, 0.03, 0.0], + [0.0, 0.0, -0.01, -0.0, -0.0, 0.04, -0.0, 0.0, -0.03], + [-1.15, 0.55, 0.0, -0.05, 0.1, -0.0, 1.2, -0.65, 0.0], + [0.75, -0.63, -0.0, -0.1, 0.03, 0.0, -0.65, 0.6, -0.0], + [-0.0, 0.0, -0.02, 0.0, 0.0, -0.03, 0.0, -0.0, 0.05], + ] + ) + + xtb_num_hess = np.array( + [ + [1.85, 0.01, 0.0, -0.93, -0.6, 0.0, -0.92, 0.59, -0.0], + [0.01, 1.13, 0.0, -0.46, -0.57, 0.0, 0.45, -0.56, 0.0], + [0.0, 0.0, 0.05, -0.0, -0.0, -0.06, 0.0, -0.0, -0.06], + [-0.93, -0.46, -0.0, 1.02, 0.53, -0.0, -0.09, -0.07, 0.0], + [-0.6, -0.57, -0.0, 0.53, 0.51, -0.0, 0.07, 0.06, -0.0], + [0.0, 0.0, -0.06, -0.0, -0.0, 0.04, -0.0, -0.0, 0.05], + [-0.92, 0.45, 0.0, -0.09, 0.07, -0.0, 1.0, -0.52, 0.0], + [0.59, -0.56, -0.0, -0.07, 0.06, -0.0, -0.52, 0.5, -0.0], + [-0.0, 0.0, -0.06, 0.0, -0.0, 0.05, 0.0, -0.0, 0.05], + ] + ) + + orca = ORCA() + orca.path = here # spoof ORCA install + assert orca.is_available + + water = Molecule( + name="water_partial_num_hess", + charge=0, + mult=1, + atoms=[ + Atom("O", -0.00110, 0.36310, -0.00000), + Atom("H", -0.82500, -0.18190, -0.00000), + Atom("H", 0.82610, -0.18120, 0.00000), + ], + ) + + calculator = HybridHessianCalculator( + water, + idxs=(0,), + shift=Distance(0.001, units="Å"), + hmethod=orca, + lmethod=XTB(), + ) + calculator.calculate() + partial_hess = calculator.hessian + """ + Partial Hessian should have structure + + ( A B ) + H = ( ) + ( B C ) + """ + + # Block A for the displacement for atom 0 should be identical to the + # total ORCA hessian + assert np.allclose(partial_hess[:3, :3], orca_num_hess[:3, :3], atol=1e-2) + + # while block C should be just the XTB numerical Hessian + assert np.allclose(partial_hess[3:, 3:], xtb_num_hess[3:, 3:], atol=1e-2) + + # who knows what the off diagonals should be... + assert np.allclose( + partial_hess[3:, :3], + (xtb_num_hess[3:, :3] + orca_num_hess[3:, :3]) / 2.0, + atol=1e-1, + ) + + +@testutils.requires_working_xtb_install +@work_in_tmp_dir() +def test_numerical_hessian_in_process_pool(): + """ + Ensure that no exceptions are raised when a numerical hessian is + calculated within a process pool + """ + with ProcessPool(max_workers=2) as pool: + res = pool.submit(_calc_num_hessian_h2) + _ = res.result(timeout=None) + + +def _calc_num_hessian_h2(): + assert mp.parent_process() is not None + h2 = Molecule(smiles="[H][H]") + h2.calc_hessian(method=XTB(), numerical=True, n_cores=1) + + +@testutils.requires_working_xtb_install +@work_in_tmp_dir() +def test_serial_calculation_matches_parallel(): + h2 = Molecule(atoms=[Atom("H"), Atom("H", x=0.77)]) + xtb = XTB() + + nhc = NumericalHessianCalculator( + species=h2, + method=xtb, + keywords=xtb.keywords.grad, + do_c_diff=False, + shift=Distance(0.01, units="Å"), + ) + nhc.calculate() + parallel_result = nhc.hessian.copy() + nhc._calculated_rows.clear() + + nhc._calculate_in_serial() + serial_result = nhc.hessian + + assert np.allclose(parallel_result, serial_result) + + +@work_in_tmp_dir() +def test_hessian_pickle_and_unpickle(): + mol = Molecule(smiles="O") + mol.hessian = Hessian(np.eye(3 * mol.n_atoms), atoms=mol.atoms) + + with open("tmp.obj", "wb") as file: + pickle.dump(mol.hessian, file=file) + + with open("tmp.obj", "rb") as file: + reloaded_hessian = pickle.load(file=file) + + assert reloaded_hessian.shape == (3 * mol.n_atoms, 3 * mol.n_atoms) + assert reloaded_hessian.atoms == mol.atoms + + +def test_hessian_proj_freqs_acetylene(): + # fmt: off + raw_hessian = np.array([ + [ 1.9130e-01, 0, 0, -1.1500e-01, 0, 0, -9.3200e-02, 0, -0, 1.7000e-02,-0, 0], + [ 0, 1.9130e-01, -0, -0, -1.1500e-01, 0, 0, -9.3200e-02, -0, 0, 1.7000e-02, 0], + [ 0, -0, 5.5863e+00, -0, 0, -4.1050e+00, -0, -0, -1.5068e+00, 0, 0, 2.5400e-02], + [-1.1500e-01, 0, -0, 1.9200e-01, -0, -0, 1.7100e-02, -0, 0, -9.4000e-02, 0, 0], + [ 0, -1.1500e-01, -0, -0, 1.9200e-01, -0, 0, 1.7100e-02, 0, -0, -9.4000e-02, 0], + [ 0, 0, -4.1050e+00, -0, -0, 5.5804e+00, 0, 0, 2.5400e-02, 0, -0, -1.5010e+00], + [-9.3300e-02, 0, -0, 1.7100e-02, 0, 0, 5.7900e-02, -0, 0, 1.8200e-02, 0, 0], + [ 0, -9.3300e-02, -0, -0, 1.7100e-02, 0, -0, 5.7900e-02, -0, -0, 1.8200e-02, 0], + [-0, -0, -1.5068e+00, 0, 0, 2.5400e-02, 0, -0, 1.4819e+00, -0, -0, -3.0000e-04], + [ 1.7000e-02, 0, 0, -9.4000e-02, -0, 0, 1.8200e-02, -0, -0, 5.8600e-02, -0, -0], + [-0, 1.7000e-02, 0, 0, -9.4000e-02, -0, 0, 1.8200e-02, -0, -0, 5.8600e-02, -0], + [ 0, 0, 2.5400e-02, 0, 0, -1.5010e+00, 0, 0, -3.0000e-04, -0, 0, 1.4761e+00] + ]) + # fmt: on + atoms = Atoms( + [ + Atom("C", 0.0000, 0.0000, -0.6043), + Atom("C", 0.0000, 0.0000, 0.6042), + Atom("H", 0.0000, 0.0000, -1.6791), + Atom("H", 0.0000, 0.0000, 1.6797), + ] + ) + + h = Hessian(atoms=atoms, input_array=raw_hessian) + freqs = h.frequencies_proj + assert len([v for v in freqs if abs(v.to("cm-1")) < 10.0]) == 5 + assert ( + len([v for v in freqs if np.isclose(v.to("cm-1"), 694.4, atol=1)]) == 2 + ) + assert len([v for v in freqs if 10 < v.to("cm-1") < 600]) == 0 + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "hessians.zip")) +def test_g09_hessian_no_input_orientation(): + c2h6 = ade.Molecule( + atoms=[ + Atom("C", 0.75249, -0.06314, 0.08226), + Atom("C", -0.75224, 0.06326, -0.0821), + Atom("H", 1.25058, -0.25289, -0.88138), + Atom("H", 1.19248, 0.85458, 0.50287), + Atom("H", 1.01813, -0.89237, 0.75653), + Atom("H", -1.01825, 0.89231, -0.75642), + Atom("H", -1.2509, 0.25255, 0.88138), + Atom("H", -1.19219, -0.85439, -0.50304), + ] + ) + + calc = Calculation( + name="tmp", + molecule=c2h6, + method=ade.methods.G09(), + keywords=ade.HessianKeywords(), + ) + calc.set_output_filename("C2H6_hess_g09_no_input_orientation.log") + + assert np.isclose( + c2h6.hessian.frequencies[-1], Frequency(3156.1252), atol=1.0 + ) diff --git a/autodE/source/tests/test_import.py b/autodE/source/tests/test_import.py new file mode 100644 index 0000000000000000000000000000000000000000..db90f0fd33eb33bf57133ab1af3f3ba95b87667d --- /dev/null +++ b/autodE/source/tests/test_import.py @@ -0,0 +1,32 @@ +"""Tests for the import speed of autode.""" +import sys + +import pytest + +SLOW_IMPORTS = ["matplotlib"] + + +@pytest.fixture +def unimport_slow_imports(): + """Remove modules in ``SLOW_IMPORTS`` from ``sys.modules``.""" + for module in SLOW_IMPORTS: + if module in sys.modules: + del sys.modules[module] + + +@pytest.mark.usefixtures("unimport_slow_imports") +def test_slow_imports_during_tab_completion(): + """Check that importing autode does not import certain python modules that would make import slow.""" + + # Let's double check that the undesired imports are not already loaded + for modulename in SLOW_IMPORTS: + assert ( + modulename not in sys.modules + ), f"Module `{modulename}` was not properly unloaded" + + import autode + + for modulename in SLOW_IMPORTS: + assert ( + modulename not in sys.modules + ), f"Detected loaded module {modulename} after autode import" diff --git a/autodE/source/tests/test_input_output.py b/autodE/source/tests/test_input_output.py new file mode 100644 index 0000000000000000000000000000000000000000..2066cc6431658e5510e7c6234ab2594dfd4a8dc6 --- /dev/null +++ b/autodE/source/tests/test_input_output.py @@ -0,0 +1,132 @@ +import sys + +import numpy as np + +from autode.input_output import ( + xyz_file_to_atoms, + atoms_to_xyz_file, + xyz_file_to_molecules, +) +from autode.exceptions import XYZfileDidNotExist, XYZfileWrongFormat +from autode.atoms import Atom, Atoms +from autode.utils import work_in_tmp_dir +from . import testutils +import pytest +import os + +here = os.path.dirname(os.path.abspath(__file__)) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "input_output.zip")) +def test_xyz_file_to_atoms(): + atoms = xyz_file_to_atoms(filename="opt_orca.xyz") + assert len(atoms) == 5 + assert type(atoms) == Atoms + assert type(atoms[0]) == Atom + assert atoms[0].coord[0] == -0.137572 + + with pytest.raises(XYZfileDidNotExist): + xyz_file_to_atoms(filename="test") + + with pytest.raises(XYZfileWrongFormat): + xyz_file_to_atoms(filename="opt_orca_broken.xyz") + + with pytest.raises(XYZfileWrongFormat): + xyz_file_to_atoms(filename="opt_orca_broken2.xyz") + + with pytest.raises(XYZfileWrongFormat): + xyz_file_to_atoms(filename="wrong_ext.mol") + + +@work_in_tmp_dir() +def test_xyz_file_incorrect_n_atoms(): + with open("test.xyz", "w") as xyz_file: + print( + "2", + "wrongly declared number of atoms", + "H 0.0 0.0 0.0", + sep="\n", + file=xyz_file, + ) + + with pytest.raises(XYZfileWrongFormat): + _ = xyz_file_to_atoms("test.xyz") + + +@work_in_tmp_dir() +def test_xyz_file_incorrect_first_line(): + with open("test.xyz", "w") as xyz_file: + print( + "XXX", + "wrong first line", + "H 0.0 0.0 0.0", + sep="\n", + file=xyz_file, + ) + + with pytest.raises(XYZfileWrongFormat): + _ = xyz_file_to_atoms("test.xyz") + + +@work_in_tmp_dir() +def test_making_xyz_file(): + atoms = [Atom("H"), Atom("H")] + + atoms_to_xyz_file(atoms, filename="test.xyz") + atoms_to_xyz_file(atoms, filename="test.xyz", append=False) + + xyz_lines = open("test.xyz", "r").readlines() + assert len(xyz_lines) == 4 + + # Simulate situations when the coordinates are super large (or small) + atoms[0].coord = (sys.float_info.min, sys.float_info.max, -1e6) + atoms_to_xyz_file(atoms, filename="test.xyz", append=False) + + # check if the first atom is written correctly: the third line (index 2) + xyz_lines = open("test.xyz", "r").readlines() + assert len(xyz_lines[2].split()) == 4 and np.isclose( + float(xyz_lines[2].split()[-1]), -1e6 + ) + + # With append should add the next set of atoms to the same file + atoms_to_xyz_file(atoms, filename="test.xyz", append=True) + + xyz_lines = open("test.xyz", "r").readlines() + assert len(xyz_lines) == 8 + + with pytest.raises(AssertionError): + # Requires some atoms + atoms_to_xyz_file(atoms=None, filename="test.xyz") + + with pytest.raises(AssertionError): + # Needs .xyz extension + atoms_to_xyz_file(atoms, filename="test") + + +def _print_xyz_file_two_h_molecules(title_line: str = "title line") -> None: + xyz_file_string = "1\n" f"{title_line}\n" "H 0.0 0.0 0.0" + + with open("tmp.xyz", "w") as file: + print(xyz_file_string, xyz_file_string, sep="\n", file=file) + + +@work_in_tmp_dir() +def test_reading_multi_molecule_file_no_defined_values(): + _print_xyz_file_two_h_molecules() + molecules = xyz_file_to_molecules("tmp.xyz") + assert len(molecules) == 2 + m = molecules[0] + + # Molecules should be generated with sensible defaults + assert m.energy is None + assert m.charge == 0 + assert m.mult == 1 + + +@work_in_tmp_dir() +def test_reading_multi_molecule_file_defined_charge_mult_energy(): + _print_xyz_file_two_h_molecules(title_line="E = -0.5 charge = 0 mult = 2") + m = xyz_file_to_molecules("tmp.xyz")[0] + assert m.energy is not None and np.isclose(m.energy, -0.5) + assert m.charge == 0 + assert m.mult == 2 diff --git a/autodE/source/tests/test_locate_tss.py b/autodE/source/tests/test_locate_tss.py new file mode 100644 index 0000000000000000000000000000000000000000..0f8b20c9150bbff348138e94e94735955546ff19 --- /dev/null +++ b/autodE/source/tests/test_locate_tss.py @@ -0,0 +1,75 @@ +import os +import pytest +import autode.exceptions as ex +from autode.atoms import Atom +from autode import Reactant, Product, Reaction +from autode.species.complex import ReactantComplex, ProductComplex +from autode.reactions.reaction_types import Dissociation +from autode.bond_rearrangement import get_bond_rearrangs, BondRearrangement +from autode.transition_states.locate_tss import ( + get_ts, + ts_guess_funcs_prms, + find_tss, +) + + +def test_one_to_three_dissociation(): + r = Reactant(name="tet_int", smiles="CC(OS(Cl)=O)(Cl)O") + p1 = Product(name="acyl", smiles="CC(Cl)=[OH+]") + p2 = Product(name="so2", smiles="O=S=O") + p3 = Product(name="chloride", smiles="[Cl-]") + + reaction = Reaction(r, p1, p2, p3, solvent_name="thf") + assert reaction.type is Dissociation + + # Generate reactants and product complexes then find the single possible + # bond rearrangement + reactant, product = reaction.reactant, reaction.product + bond_rearrangs = get_bond_rearrangs(reactant, product, name=str(reaction)) + assert len(bond_rearrangs) == 1 + os.remove(f"{str(reaction)}_BRs.txt") + + # This dissociation breaks two bonds and forms none + bond_rearrangement = bond_rearrangs[0] + assert len(bond_rearrangement.fbonds) == 0 + assert len(bond_rearrangement.bbonds) == 2 + + # Ensure there is at least one bond function that could give the TS + try: + ts_funcs_params = ts_guess_funcs_prms( + str(reaction), reactant, product, bond_rearrangement + ) + assert len(list(ts_funcs_params)) > 0 + + # Allow this function to be run with no avail EST methods + except ex.MethodUnavailable: + pass + + +def test_more_forming_than_breaking(): + h_a = Reactant(atoms=[Atom("H")], name="h_a") + h_b = Reactant(atoms=[Atom("H")], name="h_b") + h2_sep = ReactantComplex(h_a, h_b) + + h2 = ProductComplex(Product(atoms=[Atom("H"), Atom("H", x=1)], name="h2")) + + rxn = Reaction(h_a, h_b, h2) + bond_rearr = BondRearrangement(forming_bonds=[(0, 1)], breaking_bonds=None) + assert bond_rearr.n_fbonds > bond_rearr.n_bbonds + + # Number of bonds in the product needs to be the same or fewer than + # the reactant currently. Will need more get_ts_guess_function_and_params + # if this is to be supproted + with pytest.raises(NotImplementedError): + # name, reactant, product + _ = get_ts( + name=str(rxn), reactant=h2_sep, product=h2, bond_rearr=bond_rearr + ) + + +def test_find_tss_no_products(): + reaction = Reaction(Reactant(smiles="O"), Product(smiles="O")) + + # Check for no reactant and product before anything else + with pytest.raises(ValueError): + _ = find_tss(reaction) diff --git a/autodE/source/tests/test_log.py b/autodE/source/tests/test_log.py new file mode 100644 index 0000000000000000000000000000000000000000..6563999203b91849b2f903e19c8d0667758e6d86 --- /dev/null +++ b/autodE/source/tests/test_log.py @@ -0,0 +1,29 @@ +from autode.log import log +import os + + +def test_log_level(): + if "AUTODE_LOG_LEVEL" in os.environ: + set_log_level = os.environ.pop("AUTODE_LOG_LEVEL") + else: + set_log_level = "INFO" + + assert log.get_log_level() == log.logging.CRITICAL + + os.environ["AUTODE_LOG_LEVEL"] = "DEBUG" + assert log.get_log_level() == log.logging.DEBUG + + os.environ["AUTODE_LOG_LEVEL"] = "ERROR" + assert log.get_log_level() == log.logging.ERROR + + os.environ["AUTODE_LOG_LEVEL"] = "WARNING" + assert log.get_log_level() == log.logging.WARNING + + os.environ["AUTODE_LOG_LEVEL"] = "INFO" + assert log.get_log_level() == log.logging.INFO + + # Setting AUTODE_LOG_FILE to anything should log to a log file + os.environ["AUTODE_LOG_FILE"] = "true" + assert log.log_to_log_file() is True + + os.environ["AUTODE_LOG_LEVEL"] = set_log_level diff --git a/autodE/source/tests/test_methods.py b/autodE/source/tests/test_methods.py new file mode 100644 index 0000000000000000000000000000000000000000..bd3078f130c343ba19134955e88d8c30b749cc25 --- /dev/null +++ b/autodE/source/tests/test_methods.py @@ -0,0 +1,129 @@ +from autode import methods +from autode import Config +from .test_opt.setup import Method +from autode.exceptions import MethodUnavailable, NotImplementedInMethod +from autode.wrappers.XTB import XTB +from autode.wrappers.ORCA import ORCA +import pytest +import os + +here = os.path.dirname(os.path.abspath(__file__)) + + +def test_get_hmethod(): + Config.hcode = None + Config.ORCA.path = here # A path that exists + + method1 = methods.get_hmethod() + assert method1.name == "orca" + + methods.Config.hcode = "orca" + method2 = methods.get_hmethod() + assert method2.name == "orca" + + Config.hcode = "g09" + Config.G09.path = here + method3 = methods.get_hmethod() + assert method3.name == "g09" + + Config.hcode = "NwChem" + Config.NWChem.path = here + method4 = methods.get_hmethod() + assert method4.name == "nwchem" + + with pytest.raises(MethodUnavailable): + Config.hcode = "x" + methods.get_hmethod() + + +def test_get_lmethod(): + Config.lcode = None + Config.XTB.path = here + + method3 = methods.get_lmethod() + assert method3.name == "xtb" + + Config.lcode = "xtb" + method4 = methods.get_lmethod() + assert method4.name == "xtb" + + Config.lcode = "mopac" + Config.MOPAC.path = here + + method4 = methods.get_lmethod() + assert method4.name == "mopac" + + +def test_method_unavailable(): + Config.hcode = None + + Config.ORCA.path = "/an/incorrect/path" + Config.NWChem.path = "/an/incorrect/path" + Config.G09.path = "/an/incorrect/path" + + with pytest.raises(MethodUnavailable): + methods.get_hmethod() + + # Specifying a method that with an executable that doesn't exist should + # raise an error + Config.hcode = "ORCA" + + with pytest.raises(MethodUnavailable): + methods.get_hmethod() + + +def test_nwchem_ecps(): + nwchem = methods.NWChem() + assert nwchem.keywords.opt.ecp is not None + assert nwchem.keywords.sp.ecp is not None + assert nwchem.keywords.sp.ecp.nwchem is not None + + +def test_method_equality(): + orca = methods.ORCA() + g09 = methods.G09() + + assert orca == methods.ORCA() + assert orca != g09 + + orca.keywords.sp = "Some different keywords" + default_orca = methods.ORCA() + + # Single point keywords are different, so the methods are different + assert orca.keywords.sp != default_orca.keywords.sp + assert orca != methods.ORCA() + + +def test_get_method_or_default_lmethod(): # l <=> lower + Config.lcode = None + Config.XTB.path = here # spoof an XTB install + + assert methods.method_or_default_lmethod(None) is not None + assert isinstance(methods.method_or_default_lmethod(XTB()), XTB) + + +def test_get_method_or_default_hmethod(): # h <=> higher + Config.hcode = None + Config.ORCA.path = here # spoof an XTB install + + assert methods.method_or_default_hmethod(None) is not None + assert isinstance(methods.method_or_default_lmethod(ORCA()), ORCA) + + +def test_methods_in_base_class_raise_runtime_errors(): + method_names = [ + "optimiser_from", + "energy_from", + "gradient_from", + "hessian_from", + "coordinates_from", + "atoms_from", + "partial_charges_from", + "input_filename_for", + "output_filename_for", + ] + + for method_name in method_names: + method = getattr(Method(), method_name) + with pytest.raises(NotImplementedInMethod): + method(None) diff --git a/autodE/source/tests/test_molecule.py b/autodE/source/tests/test_molecule.py new file mode 100644 index 0000000000000000000000000000000000000000..09101a7d1028d37009392923ba8528cb8ac7e372 --- /dev/null +++ b/autodE/source/tests/test_molecule.py @@ -0,0 +1,273 @@ +from autode.species.molecule import Molecule +from autode.conformers import Conformer +from autode.exceptions import NoAtomsInMolecule +from autode.geom import are_coords_reasonable +from autode.input_output import atoms_to_xyz_file +from autode.smiles.smiles import calc_multiplicity, init_organic_smiles +from autode.wrappers.ORCA import orca +from autode.species.molecule import Reactant, Product +from autode.atoms import Atom +from rdkit.Chem import Mol +from . import testutils +import numpy as np +import pytest +import os + +here = os.path.dirname(os.path.abspath(__file__)) + + +def test_basic_attributes(): + methane = Molecule(name="methane", smiles="C") + + assert methane.name == "methane" + assert methane.smiles == "C" + + assert repr(methane) != "" # Have some simple representation + + assert methane.energy is None + assert methane.n_atoms == 5 + assert methane.graph.number_of_edges() == 4 + assert methane.graph.number_of_nodes() == methane.n_atoms + assert methane.n_conformers == 0 + assert methane.charge == 0 + assert methane.mult == 1 + assert isinstance(methane.rdkit_mol_obj, Mol) + + assert np.isclose(methane.eqm_bond_distance(0, 1), 1.1, atol=0.2) # Å + + # A molecule without a name should default to the formula + methane = Molecule(smiles="C") + assert methane.name == "CH4" or methane.name == "H4C" + + atoms_to_xyz_file(atoms=[Atom("H")], filename="tmp_H.xyz") + # Cannot create a molecule with an odd number of electrons with charge = 0 + # and spin multiplicity of 1 + with pytest.raises(ValueError): + _ = Molecule("tmp_H.xyz") + + # but is fine as a doublet + h_atom = Molecule("tmp_H.xyz", mult=2) + assert h_atom.mult == 2 + + # or as a proton + h_atom = Molecule("tmp_H.xyz", charge=1) + assert h_atom.charge == 1 and h_atom.mult == 1 + + os.remove("tmp_H.xyz") + + +def test_bond_matrix(): + water = Molecule(smiles="O") + # check there are bonds where they are expected + + bond_matrix = water.bond_matrix + + assert not bond_matrix[0, 0] + assert bond_matrix[0, 1] # O-H + assert bond_matrix[0, 2] # O-H + assert bond_matrix[1, 0] # H-O + assert bond_matrix[2, 0] # H-O + assert not bond_matrix[1, 2] # H-H + + # No self bonds + assert not bond_matrix[1, 1] + assert not bond_matrix[2, 2] + + +def test_gen_conformers(): + ethane = Molecule(name="ethane", smiles="CC") + ethane._generate_conformers(n_confs=2) + + assert ethane.rdkit_conf_gen_is_fine + + # Even though two conformers have been requested they are pruned on RMSD + assert len(ethane.conformers) >= 1 + assert type(ethane.conformers[0]) == Conformer + assert ethane.conformers[0].energy is None + assert ethane.conformers[0].n_atoms == 8 + + with pytest.raises(NoAtomsInMolecule): + mol = Molecule() + mol._generate_conformers() + + # Metal complexes must be completely bonded entities + with pytest.raises(Exception): + _ = Molecule(smiles="[Pd]C.C") + + +def test_siman_conf_gen(tmpdir): + os.chdir(tmpdir) + + rh_complex = Molecule( + name="[RhH(CO)3(ethene)]", smiles="O=C=[Rh]1(=C=O)(CC1)([H])=C=O" + ) + assert are_coords_reasonable(coords=rh_complex.coordinates) + assert rh_complex.n_atoms == 14 + assert 12 < rh_complex.graph.number_of_edges() < 15 # What is a bond even + + # Should be able to generate even crazy molecules + mol = Molecule(smiles="C[Fe](C)(C)(C)(C)(C)(C)(C)C") + assert mol.atoms is not None + + os.chdir(here) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "molecule.zip")) +def test_molecule_opt(): + mol = Molecule(name="H2", smiles="[H][H]") + + # Set the orca path to something that exists + orca.path = here + + mol.optimise(method=orca) + assert mol.energy == -1.160687049941 + assert mol.n_atoms == 2 + + opt_coords = mol.coordinates + # H2 bond length ~ 0.767 Å at PBE/def2-SVP + assert 0.766 < np.linalg.norm(opt_coords[0] - opt_coords[1]) < 0.768 + + +def calc_mult(): + h = Molecule(name="H", smiles="[H]") + assert h.mult == 2 + + assert calc_multiplicity(h, n_radical_electrons=1) == 2 + + # Setting the multiplicity manually should override the number of radical + # electrons derived from the SMILES string + # note: H with M=3 is obviously not possible + h.mult = 3 + assert calc_multiplicity(h, n_radical_electrons=1) == 3 + + # Diradicals should default to singlets.. + assert calc_multiplicity(h, n_radical_electrons=2) == 1 + + +def test_reactant_to_product_and_visa_versa(): + prod = Reactant().to_product() + assert type(prod) is Product + + reac = Product().to_reactant() + assert type(reac) is Reactant + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "molecule.zip")) +def test_molecule_from_xyz(): + h2 = Molecule("h2_conf0.xyz") + assert h2.name == "h2_conf0" + assert h2.n_atoms == 2 + assert h2.formula == "H2" + + # Molecules loaded from .xyz directly can still have names + h2_named = Molecule("h2_conf0.xyz", name="tmp") + assert h2_named.name == "tmp" + + # Name kwarg takes priority even if arg is defined + h2_named2 = Molecule("tmp", name="tmp2") + assert h2_named2.name == "tmp2" + + +def test_rdkit_possible_fail(): + """RDKit can't generate structures for some SMILES, make sure they can + be generated in other ways""" + + rh_complex = Molecule(smiles="C[Rh](=C=O)(=C=O)(=C=O)=C=O") + assert are_coords_reasonable(coords=rh_complex.coordinates) + + # Trying to parse with RDKit should revert to RR structure + rh_complex_rdkit_attempt = Molecule() + init_organic_smiles( + rh_complex_rdkit_attempt, smiles="O=[Rh]([H])([H])([H])=O" + ) + assert are_coords_reasonable(coords=rh_complex.coordinates) + + # RDKit also may not parse CH5+ + ch5 = Molecule(smiles="[H]C([H])([H])([H])[H+]") + assert are_coords_reasonable(coords=ch5.coordinates) + + +def test_multi_ring_smiles_init(): + cr_complex = Molecule( + smiles="[N+]12=CC=CC3=C1C(C(C=C3)=CC=C4)=[N+]4[Cr]25" + "6([N+]7=CC=CC8=C7C9=[N+]6C=CC=C9C=C8)[N+]%10" + "=CC=CC%11=C%10C%12=[N+]5C=CC=C%12C=C%11" + ) + + assert are_coords_reasonable(cr_complex.coordinates) + + +def test_prune_diff_graphs(): + h2 = Molecule(smiles="[H][H]") + h2.energy = -1.0 + assert h2.graph is not None + + h2_not_bonded = Molecule(atoms=[Atom("H"), Atom("H", x=10)]) + h2_not_bonded.energy = -1.1 + # no bonds for a long H-bond + assert np.allclose(h2_not_bonded.bond_matrix, np.zeros(shape=(2, 2))) + + h2.conformers = [h2_not_bonded] + + h2.conformers.prune_diff_graph(graph=h2.graph) + + # Should prune all conformers + assert h2.n_conformers == 0 + + +def test_lowest_energy_conformer_set_ok(): + h2 = Molecule(smiles="[H][H]") + h2.energy = -1.0 + + h2_long = Molecule(atoms=[Atom("H"), Atom("H", x=0.5)]) + h2_long.energy = -1.1 + + h2.conformers = [h2_long] + + # Setting the lowest energy conformer should override the atoms + # and energy of the molecule + h2._set_lowest_energy_conformer() + + assert h2.energy == -1.1 + assert np.isclose(h2.distance(0, 1).to("ang"), 0.5) + + +def test_lowest_energy_conformer_set_no_energy(): + h2 = Molecule(smiles="[H][H]") + h2.energy = -1.0 + + # No lowest energy conformer without any conformers.. + assert h2.conformers.lowest_energy is None + + h2_conf = Molecule(atoms=[Atom("H"), Atom("H", x=1)]) + assert h2_conf.energy is None + + h2.conformers = [h2_conf] + assert h2.conformers.lowest_energy is None + + # Cannot set the lowest energy with no conformers having defined energies + with pytest.raises(Exception): + h2_conf._set_lowest_energy_conformer() + + +def test_defined_metal_spin_state(): + mol = Molecule(smiles="[Sc]C", mult=3) + assert mol.mult == 3 + + +def test_atom_class_defined_for_organic(): + mol = Molecule(smiles="[Br-:1]") + assert mol.atoms[0].atom_class is not None + + +def test_smiles_and_user_defined_charge_raises_exception(): + with pytest.raises(Exception): + _ = Molecule(smiles="[Cl-]", charge=1) + + +def test_user_defined_charge_overrides_smiles_mult(): + ch2 = Molecule(smiles="[H][C][H]") + default_mult = ch2.mult + + ch2_alt = Molecule(smiles="[H][C][H]", mult=3 if default_mult == 1 else 1) + assert ch2_alt.mult != default_mult diff --git a/autodE/source/tests/test_multistep.py b/autodE/source/tests/test_multistep.py new file mode 100644 index 0000000000000000000000000000000000000000..37d2a0a23b7e0ecdb62f622bb5c6260031e883d2 --- /dev/null +++ b/autodE/source/tests/test_multistep.py @@ -0,0 +1,126 @@ +import pytest +from autode.config import Config +from autode.reactions.reaction import Reaction +from autode.reactions.multistep import MultiStepReaction +from autode.species import Reactant, Product +from autode.atoms import Atom +from autode.wrappers.keywords import cpcm +from . import testutils +import shutil +import os + +here = os.path.dirname(os.path.abspath(__file__)) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "multistep.zip")) +@testutils.requires_working_xtb_install +def test_multistep_reaction(): + Config.num_conformers = 1 + + # Spoof installs + Config.lcode = "xtb" + Config.XTB.path = shutil.which("xtb") + + Config.hcode = "orca" + Config.ORCA.path = here + + Config.ORCA.implicit_solvation_type = cpcm + Config.make_ts_template = False + Config.num_complex_sphere_points = 2 + Config.num_complex_random_rotations = 1 + + # SN2 forwards then backwards example + forwards = Reaction( + "CCl.[F-]>>CF.[Cl-]", name="sn2_forwards", solvent_name="water" + ) + + backwards = Reaction( + "CF.[Cl-]>>CCl.[F-]", name="sn2_backwards", solvent_name="water" + ) + + reaction = MultiStepReaction(forwards, backwards) + reaction.calculate_reaction_profile() + + assert reaction.reactions is not None + assert len(reaction.reactions) == 2 + assert reaction.reactions[0].ts is not None + + +def test_balancing(): + """Test that a multistep reaction can balance using addition of + spectator molecules in some reactions""" + h2 = Product(atoms=[Atom("H"), Atom("H", x=0.7)]) + + r1 = Reaction(Reactant(atoms=[Atom("H")]), Reactant(atoms=[Atom("H")]), h2) + + h2_he = Product(atoms=[Atom("H"), Atom("H", x=0.5), Atom("He", x=1.0)]) + + r2 = Reaction(h2.to_reactant(), Reactant(atoms=[Atom("He")]), h2_he) + + # Should be able to form a multistep reaction, even if the total number of + # atoms doesn't balance between reactions + rxn = MultiStepReaction(r1, r2) + + assert rxn.reactions[0].atomic_symbols != rxn.reactions[1].atomic_symbols + + # Balancing should add a He atom to the reactants and products of + # the first reaction + rxn._balance() + assert rxn.reactions[0].atomic_symbols == rxn.reactions[1].atomic_symbols + assert sum(m.atomic_symbols == ["He"] for m in rxn.reactions[0].reacs) == 1 + assert sum(m.atomic_symbols == ["He"] for m in rxn.reactions[0].prods) == 1 + + # New reaction where a He atom is removed i.e. + # H2 + He -> H2.He + # H2 -> H + H + + rev_rxn = MultiStepReaction(r2, r1) + rev_rxn._balance() + first_rxn, second_rxn = rev_rxn.reactions + + # Now the 2nd reaction should have additional He atoms + assert first_rxn.has_identical_composition_as(second_rxn) + assert sum(m.atomic_symbols == ["He"] for m in second_rxn.reacs) == 1 + assert sum(m.atomic_symbols == ["He"] for m in second_rxn.prods) == 1 + + +def test_impossible_balance(): + with pytest.raises(ValueError): + # No previous reaction to the first + MultiStepReaction()._set_reactants_from_previous_products(0) + + r1 = Reaction( + Reactant(atoms=[Atom("I")]), + Reactant(atoms=[Atom("I")]), + Product(atoms=[Atom("I"), Atom("I", x=2.0)]), + ) + + r2 = Reaction( + Reactant(atoms=[Atom("Cl")]), + Reactant(atoms=[Atom("Cl")]), + Product(atoms=[Atom("Cl"), Atom("Cl", x=1.8)]), + ) + + rxn = MultiStepReaction(r1, r2) + with pytest.raises(RuntimeError): + rxn._balance() + + with pytest.raises(RuntimeError): + rxn._set_reactants_from_previous_products(step_idx=1) + + r3 = Reaction( + Reactant(atoms=[Atom("Cl"), Atom("Cl", x=1.8)]), + Product(atoms=[Atom("Cl")]), + Product(atoms=[Atom("Cl")]), + ) + identity_rxn = MultiStepReaction(r2, r3) + + # No added molecule for an a multistep reaction with two identical rxns + with pytest.raises(RuntimeError): + _ = identity_rxn._added_molecule(step_idx=0, next_step_idx=1) + + +def test_multistep_reaction_invalid_init(): + # Must form a multistep reaction out of Reaction instances + with pytest.raises(ValueError): + _ = MultiStepReaction("a") diff --git a/autodE/source/tests/test_nci_complex.py b/autodE/source/tests/test_nci_complex.py new file mode 100644 index 0000000000000000000000000000000000000000..40c51b9c0deff18b3aa545ecac3d88f35b46f1d0 --- /dev/null +++ b/autodE/source/tests/test_nci_complex.py @@ -0,0 +1,32 @@ +from autode.species.complex import NCIComplex +from autode.species.molecule import Molecule +from autode.config import Config +from autode import geom + + +def test_nci_complex(): + water = Molecule(name="water", smiles="O") + f = Molecule(name="formaldehyde", smiles="C=O") + + nci_complex = NCIComplex(f, water) + assert nci_complex.n_atoms == 7 + + # Set so the number of conformers doesn't explode + Config.num_complex_sphere_points = 6 + Config.num_complex_random_rotations = 4 + + nci_complex._generate_conformers() + assert len(nci_complex.conformers) == 24 + + for conformer in nci_complex.conformers: + # conformer.print_xyz_file() + assert geom.are_coords_reasonable(coords=conformer.coordinates) + + # To view the structures generated overlaid + # -------------------------------------------------- + + # from autode.input_output import atoms_to_xyz_file + # all_atoms = [] + # for c in nci_complex.conformers: + # all_atoms += c.atoms + # atoms_to_xyz_file(atoms=all_atoms, filename='tmp.xyz') diff --git a/autodE/source/tests/test_neb.py b/autodE/source/tests/test_neb.py new file mode 100644 index 0000000000000000000000000000000000000000..87691cba67387c2b7419a691bc6ca773ee5dc8b6 --- /dev/null +++ b/autodE/source/tests/test_neb.py @@ -0,0 +1,423 @@ +import shutil +import os +import numpy as np +import pytest +from autode.path import Path +from autode.neb import NEB +from autode.values import Distance, ForceConstant +from autode.neb.ci import Images, CImages, Image +from autode.neb.idpp import IDPP +from autode.species.molecule import Species, Molecule +from autode.species.molecule import Reactant +from autode.neb.neb import get_ts_guess_neb +from autode.neb.original import energy_gradient +from autode.atoms import Atom +from autode.geom import are_coords_reasonable +from autode.input_output import xyz_file_to_atoms +from autode.utils import work_in_tmp_dir +from autode.methods import XTB, ORCA +from . import testutils + + +here = os.path.dirname(os.path.abspath(__file__)) + + +@work_in_tmp_dir() +def test_neb_properties(): + # H-H H + reac = Species( + name="reac", + charge=0, + mult=2, + atoms=[Atom("H"), Atom("H", x=0.7), Atom("H", x=2.0)], + ) + # H H-H + prod = Species( + name="prod", + charge=0, + mult=2, + atoms=[Atom("H"), Atom("H", x=1.3), Atom("H", x=2.0)], + ) + + neb = NEB.from_end_points(reac, prod, num=3) + assert len(neb.images) == 3 + assert neb.peak_species is None + assert not neb.images.contains_peak + + # Should move monotonically from 0.7 -> 1.3 Angstroms + for i in range(1, len(neb.images)): + prev_bb_dist = neb.images[i - 1].distance(0, 1) + curr_bb_dist = neb.images[i].distance(0, 1) + + assert curr_bb_dist > prev_bb_dist + + +def test_image_properties(): + k = ForceConstant(0.1) + images = CImages(images=Images(init_k=k)) + assert images != 0 + assert images == images + + images = Images(init_k=k) + assert images != 0 + assert images == images + + image = Image(Molecule(smiles="CC"), k=ForceConstant(1.0), name="tmp") + with pytest.raises(Exception): + image._generate_conformers() + + +def test_contains_peak(): + species_list = Path() + for i in range(5): + h2 = Species( + name="h2", charge=0, mult=2, atoms=[Atom("H"), Atom("H", x=0)] + ) + + h2.energy = i + species_list.append(h2) + + assert not species_list.contains_peak + + species_list[2].energy = 5 + assert species_list.contains_peak + + species_list[2].energies.clear() + species_list[2].energy = None + assert not species_list.contains_peak + + +@testutils.requires_working_xtb_install +@testutils.work_in_zipped_dir(os.path.join(here, "data", "neb.zip")) +def test_full_calc_with_xtb(): + sn2_neb = NEB.from_end_points( + initial=Species( + name="inital", + charge=-1, + mult=1, + atoms=xyz_file_to_atoms("sn2_init.xyz"), + solvent_name="water", + ), + final=Species( + name="final", + charge=-1, + mult=1, + atoms=xyz_file_to_atoms("sn2_final.xyz"), + solvent_name="water", + ), + num=14, + ) + + sn2_neb.calculate(method=XTB(), n_cores=2) + + # There should be a peak in this surface + assert sn2_neb.peak_species is not None + + assert all(image.energy is not None for image in sn2_neb.images) + + energies = [image.energy for image in sn2_neb.images] + path_energy = sum(energy - min(energies) for energy in energies) + + assert 0.25 < path_energy < 0.45 + + +@testutils.requires_working_xtb_install +@testutils.work_in_zipped_dir(os.path.join(here, "data", "neb.zip")) +def test_get_ts_guess_neb(): + reactant = Reactant( + name="inital", + charge=-1, + mult=1, + solvent_name="water", + atoms=xyz_file_to_atoms("sn2_init.xyz"), + ) + + product = Reactant( + name="final", + charge=-1, + mult=1, + solvent_name="water", + atoms=xyz_file_to_atoms("sn2_final.xyz"), + ) + + xtb = XTB() + xtb.path = shutil.which("xtb") + + ts_guess = get_ts_guess_neb(reactant, product, method=xtb, n=10) + + assert ts_guess is not None + # Approximate distances at the TS guess + assert 1.8 < ts_guess.distance(0, 2) < 2.3 # C-F + assert 2.1 < ts_guess.distance(2, 1) < 2.6 # C-Cl + + if os.path.exists("NEB"): + shutil.rmtree("NEB") + + if os.path.exists("neb.xyz"): + os.remove("neb.xyz") + + # Trying to get a TS guess with an unavailable method should return None + # as a TS guess + orca = ORCA() + orca.path = None + + orca_ts_guess = get_ts_guess_neb(reactant, product, method=orca, n=10) + assert orca_ts_guess is None + + +def test_climbing_image(): + k = ForceConstant(0.1) + images = CImages(images=Images(init_k=k)) + images.append_species(Molecule(atoms=[Atom("H")], mult=2)) + + assert images.peak_idx is None + assert images[0].iteration == 0 + images[0].iteration = 10 + + +def _simple_h2_images(num, shift, increment): + """Simple set of images for a n-image NEB for H2""" + + images = Images(init_k=ForceConstant(1.0)) + + for i in range(num): + mol = Molecule(atoms=[Atom("H"), Atom("H", x=shift + i * increment)]) + images.append_species(mol) + + return images + + +def test_energy_gradient_type(): + k = ForceConstant(1.0) + image = Image(species=Molecule(atoms=[Atom("H")], mult=2), name="tmp", k=k) + + # Energy and gradient must have a method (EST or IDPP) + with pytest.raises(ValueError): + _ = energy_gradient(image=image, method=None, n_cores=1) + + +def test_iddp_init(): + """IDPP requires at least 2 images""" + + k = ForceConstant(0.1) + + with pytest.raises(ValueError): + _ = IDPP(Images(init_k=k)) + + with pytest.raises(ValueError): + _ = IDPP(Images(init_k=k)) + + +def test_iddp_energy(): + images = _simple_h2_images(num=3, shift=0.5, increment=0.1) + idpp = IDPP(images) + + # Should be callable to evaluate the objective function + value = idpp(images[1]) + + assert value is not None + assert np.isclose( + value, + # w r_k r + 0.6 ** (-4) * ((0.5 + 2 * 0.2 / 3) - 0.6) ** 2, + atol=1e-5, + ) + + +def test_iddp_gradient(): + images = _simple_h2_images(num=3, shift=0.5, increment=0.1) + image = images[1] + idpp = IDPP(images) + + value = idpp(image) + + # and the gradient calculable + grad = idpp.grad(image).flatten() + assert grad is not None + + # And the gradient be close to the numerical analogue + def num_grad(n, h=1e-8): + i, k = n // 3, n % 3 + + shift_vec = np.zeros(3) + shift_vec[k] = h + + image.atoms[i].translate(shift_vec) + new_value = idpp(image) + image.atoms[i].translate(-shift_vec) + + return (new_value - value) / h + + # Numerical gradient should be finite + assert not np.isclose(num_grad(0), 0.0, atol=1e-10) + + # Check all the elements in the gradient vector + for i, analytic_value in enumerate(grad): + assert np.isclose(analytic_value, num_grad(i), atol=1e-5) + + +@work_in_tmp_dir() +def test_neb_interpolate_and_idpp_relax(): + mol = Molecule( + name="methane", + atoms=[ + Atom("C", -0.91668, 0.42765, 0.00000), + Atom("H", 0.15332, 0.42765, 0.00000), + Atom("H", -1.27334, 0.01569, -0.92086), + Atom("H", -1.27334, 1.43112, 0.10366), + Atom("H", -1.27334, -0.16385, 0.81720), + ], + ) + + rot_mol = mol.copy() + rot_mol.rotate(axis=[1.0, 0.0, 0.0], theta=1.5) + + neb = NEB.from_end_points(initial=mol, final=rot_mol, num=10) + + for image in neb.images: + assert are_coords_reasonable(image.coordinates) + + +def test_max_delta_between_images(): + _list = [ + Molecule(atoms=[Atom("H"), Atom("H", x=2.7)]), + Molecule(atoms=[Atom("H"), Atom("H", x=1.7)]), + ] + + assert np.isclose( + NEB.from_list(_list).max_atom_distance_between_images, 1.0 + ) + + _list[0].atoms[1].coord[0] = 1.7 # x coordinate of the second atom + assert np.isclose( + NEB.from_list(_list).max_atom_distance_between_images, 0.0 + ) + + +def test_max_delta_between_images_h3(): + _list = [ + Molecule(atoms=[Atom("H"), Atom("H", x=0.7), Atom("H", x=2.7)]), + Molecule(atoms=[Atom("H"), Atom("H", x=0.70657), Atom("H", x=2.7)]), + ] + + neb = NEB.from_list(_list) + assert np.isclose(neb.max_atom_distance_between_images, 0.00657) + + assert np.isclose( + neb.max_atom_distance_between_images, + neb._max_atom_distance_between_images([0, 1]), + ) + + +def test_partition_max_delta(): + # Set of molecules that are like: [H-H...H, H--H--H, H...H-H] + _list = [ + Molecule(atoms=[Atom("H"), Atom("H", x=0.7), Atom("H", x=2.7)]), + Molecule(atoms=[Atom("H"), Atom("H", x=1.35), Atom("H", x=2.7)]), + Molecule(atoms=[Atom("H"), Atom("H", x=2.0), Atom("H", x=2.7)]), + ] + + h2_h = NEB.from_list(_list) + max_delta = Distance(0.1, units="Å") + + assert ( + np.max( + np.linalg.norm(_list[0].coordinates - _list[1].coordinates, axis=1) + ) + > max_delta + ) + + h2_h.partition(max_delta=max_delta) + + for i, j in [(0, 1), (1, 2)]: + assert ( + np.max( + np.linalg.norm( + h2_h.images[i].coordinates - h2_h.images[j].coordinates, + axis=1, + ) + ) + <= max_delta + ) + + +def _h_xyz_string_with_energy(energy: float): + return f"1\nE = {energy:.6f}\nH 0.0 0.0 0.0" + + +def _h_xyz_string(): + return f"1\ntitle line\nH 0.0 0.0 0.0" + + +@work_in_tmp_dir() +def test_init_from_file_sets_force_constant(): + with open("tmp.xyz", "w") as file: + print( + _h_xyz_string_with_energy(0.1), + _h_xyz_string_with_energy(0.1015), + _h_xyz_string_with_energy(0.105), + sep="\n", + file=file, + ) + + # Should be able to set the initial force constant + neb = NEB.from_file("tmp.xyz", init_k=0.234) + assert len(neb.images) == 3 + assert np.isclose(neb.init_k, 0.234) + + neb = NEB.from_file("tmp.xyz") + # Estimated value should be reasonable + k_1kcal_diffs = neb.init_k + assert 0.001 < neb.init_k < 0.2 + + with open("tmp.xyz", "w") as file: + print( + _h_xyz_string_with_energy(0.1), + _h_xyz_string_with_energy(0.25), + _h_xyz_string_with_energy(0.4), + sep="\n", + file=file, + ) + + # with larger energy differences we should have a larger k + assert NEB.from_file("tmp.xyz").init_k > k_1kcal_diffs + + +@work_in_tmp_dir() +def test_init_from_file_sets_force_constant_no_energies(): + with open("tmp.xyz", "w") as file: + print(_h_xyz_string(), _h_xyz_string(), sep="\n", file=file) + + neb = NEB.from_file("tmp.xyz") + # Estimated value should be reasonable even without energies + assert 0.001 < neb.init_k < 0.2 + + +def test_neb_constructor_with_kwargs_raises(): + with pytest.raises(Exception): + _ = NEB(init_k=ForceConstant(0.1), another_arg="a string") + + +def test_constructing_neb_from_endpoints_with_different_atoms_raises(): + with pytest.raises(Exception): + _ = NEB.from_end_points( + Molecule(smiles="O"), Molecule(smiles="C"), num=4 + ) + + +def test_neb_from_endpoints_requires_at_least_2_images(): + with pytest.raises(Exception): + _ = NEB.from_end_points( + Molecule(smiles=r"C\C=C\C"), Molecule(smiles=r"C\C=C/C"), num=1 + ) + + +@testutils.requires_working_xtb_install +@work_in_tmp_dir() +def test_neb_ts_guess_is_none_if_no_peak(): + init = Molecule(smiles="C") + final = init.copy() + final.rotate(axis=[0.1, 0.2, 0.3], theta=0.4) + + result = get_ts_guess_neb(init, final, method=XTB(), n=3) + assert result is None diff --git a/autodE/source/tests/test_opt/__init__.py b/autodE/source/tests/test_opt/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/autodE/source/tests/test_opt/data/hessians.zip b/autodE/source/tests/test_opt/data/hessians.zip new file mode 100644 index 0000000000000000000000000000000000000000..f94694b4c6859bb45a376997589fc307a2437f8f --- /dev/null +++ b/autodE/source/tests/test_opt/data/hessians.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8559c953590bdc29a495fa345b7eddc3fb7917d7887d759db09407c76d217dcc +size 1938 diff --git a/autodE/source/tests/test_opt/data/opt.zip b/autodE/source/tests/test_opt/data/opt.zip new file mode 100644 index 0000000000000000000000000000000000000000..5979c1ec343ef6a95f4f7a535f8c29f08befb99c --- /dev/null +++ b/autodE/source/tests/test_opt/data/opt.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f1dabe6686457fe1d395af113e9e7758134d7164702b3ac95da5866865f676e7 +size 50828 diff --git a/autodE/source/tests/test_opt/molecules.py b/autodE/source/tests/test_opt/molecules.py new file mode 100644 index 0000000000000000000000000000000000000000..43956fc060042fd56108ef13e88a720074b65cc4 --- /dev/null +++ b/autodE/source/tests/test_opt/molecules.py @@ -0,0 +1,88 @@ +from autode.species.molecule import Molecule +from autode.atoms import Atom + + +def h_atom(): + return Molecule(atoms=[Atom("H")], mult=2) + + +def acetylene_mol(): + return Molecule( + atoms=[ + Atom("C", 0.3800, -0.2049, -0.4861), + Atom("C", -0.3727, 0.1836, 0.4744), + Atom("H", 1.0156, -0.6055, -1.2353), + Atom("H", -0.9992, 0.5945, 1.1572), + ] + ) + + +def methane_mol(): + return Molecule( + atoms=[ + Atom("C", 0.11105, -0.21307, 0.00000), + Atom("H", 1.18105, -0.21307, 0.00000), + Atom("H", -0.24562, -0.89375, 0.74456), + Atom("H", -0.24562, -0.51754, -0.96176), + Atom("H", -0.24562, 0.77207, 0.21720), + ] + ) + + +def h2(): + return Molecule(name="h2", atoms=[Atom("H"), Atom("H", x=1.5)]) + + +def water_mol(): + return Molecule( + atoms=[ + Atom("O", -0.00110, 0.36310, 0.00000), + Atom("H", -0.82500, -0.18190, 0.00000), + Atom("H", 0.82610, -0.18120, 0.00000), + ] + ) + + +def h2o2_mol(): + return Molecule( + atoms=[ + Atom("O", -0.85156, -0.20464, 0.31961), + Atom("O", 0.41972, 0.06319, 0.10395), + Atom("H", -1.31500, 0.08239, -0.50846), + Atom("H", 0.58605, 0.91107, 0.59006), + ] + ) + + +def feco5_mol(): + return Molecule( + atoms=[ + Atom("O", -1.5139, -3.5069, -0.3015), + Atom("C", -0.8455, -2.5982, -0.3019), + Atom("Fe", 0.3545, -0.9664, -0.3020), + Atom("C", 1.4039, 0.5711, -0.2183), + Atom("O", 2.2216, 1.5751, -0.2992), + Atom("C", 1.2470, -1.6232, 1.3934), + Atom("O", 1.7445, -1.9892, 2.3373), + Atom("C", 1.0993, -1.3993, -2.0287), + Atom("O", 1.5042, -1.8102, -3.1145), + Atom("C", -1.2751, 0.2315, -0.1941), + Atom("O", -2.1828, 0.8986, -0.1345), + ] + ) + + +def cumulene_mol(): + return Molecule( + atoms=[ + Atom("C", -1.3968, 3.7829, 0.9582), + Atom("C", -0.8779, 2.7339, 0.6902), + Atom("C", -1.9158, 4.8319, 1.2262), + Atom("C", -2.4770, 5.9661, 1.5160), + Atom("C", -0.3167, 1.5996, 0.4004), + Atom("H", -2.1197, 6.8879, 1.0720), + Atom("H", -3.3113, 6.0085, 2.2063), + Atom("H", 0.1364, 1.4405, -0.5711), + Atom("H", -0.2928, 0.7947, 1.1256), + ] + ) diff --git a/autodE/source/tests/test_opt/setup.py b/autodE/source/tests/test_opt/setup.py new file mode 100644 index 0000000000000000000000000000000000000000..6e25f8d783f64d75d0222d9f3f343e7a538b27de --- /dev/null +++ b/autodE/source/tests/test_opt/setup.py @@ -0,0 +1,19 @@ +from autode.wrappers.methods import Method as BaseMethod +from autode.wrappers.keywords.keywords import KeywordsSet + + +class Method(BaseMethod): + def __init__(self): + super().__init__( + name="test_method", keywords_set=KeywordsSet(), doi_list=[] + ) + + def __repr__(self): + return f"{self.__class__.__name__}" + + def implements(self, calculation_type) -> bool: + return True + + @property + def uses_external_io(self) -> bool: + return False diff --git a/autodE/source/tests/test_opt/test_autodiff.py b/autodE/source/tests/test_opt/test_autodiff.py new file mode 100644 index 0000000000000000000000000000000000000000..eec7065d1c6954f336a5911a9bea7bec65a353e8 --- /dev/null +++ b/autodE/source/tests/test_opt/test_autodiff.py @@ -0,0 +1,190 @@ +import math +import numpy as np +import pytest +from autode.opt.coordinates._autodiff import ( + get_differentiable_vars, + DifferentiableMath, + VectorHyperDual, + DerivativeOrder, + DifferentiableVector3D, +) + + +def test_hyperdual_sanity_checks(): + a, b = get_differentiable_vars( + values=[1, 2], symbols=["a", "b"], deriv_order=DerivativeOrder.first + ) + assert repr(a) is not None + + c = get_differentiable_vars( + values=[1], symbols=["c"], deriv_order=DerivativeOrder.first + )[0] + with pytest.raises(ValueError, match="Incompatible number"): + _ = a + c + + c, d = get_differentiable_vars( + values=[1, 2], symbols=["c", "d"], deriv_order=DerivativeOrder.first + ) + with pytest.raises(ValueError, match="symbols do not match"): + _ = a * c + + a2, b2 = get_differentiable_vars( + values=[1, 2], symbols=["a", "b"], deriv_order=DerivativeOrder.second + ) + with pytest.raises(ValueError, match="order of derivative do not match!"): + _ = DifferentiableMath.atan2(a2, b) + + with pytest.raises(RuntimeError, match="Symbols must be unique"): + _ = get_differentiable_vars( + values=[1, 2], + symbols=["a", "a"], + deriv_order=DerivativeOrder.first, + ) + + +def x_to_the_y_derivs(x, y): + d_dx = y * x ** (y - 1) + d_dy = x**y * math.log(x) + return d_dx, d_dy + + +def x_to_the_y_second_derivs(x, y): + d2_dx2 = y * (y - 1) * x ** (y - 2) + d2_dxdy = math.log(x) * y * x ** (y - 1) + x**y * (1 / x) + d2_dydx = x ** (y - 1) + y * x ** (y - 1) * math.log(x) + d2_dy2 = math.log(x) * math.log(x) * x**y + return d2_dx2, d2_dxdy, d2_dydx, d2_dy2 + + +def test_autodiff_exponential_func(): + x_val, y_val = 1.0, 2.0 + x, y = get_differentiable_vars( + values=[x_val, y_val], + symbols=["x", "y"], + ) + result = x**y + d_dx, d_dy = x_to_the_y_derivs(x_val, y_val) + assert math.isclose(result.differentiate_wrt("x"), d_dx) + assert math.isclose(result.differentiate_wrt("y"), d_dy) + + d2_dx2, d2_dxdy, d2_dydx, d2_dy2 = x_to_the_y_second_derivs(x_val, y_val) + assert math.isclose(result.differentiate_wrt("x", "x"), d2_dx2) + assert math.isclose(result.differentiate_wrt("x", "y"), d2_dxdy) + assert math.isclose(result.differentiate_wrt("y", "x"), d2_dydx) + assert math.isclose(result.differentiate_wrt("y", "y"), d2_dy2) + # check the second derivatives are symmetric + assert math.isclose(d2_dydx, d2_dxdy) + + +def test_exponential_math_sanity_checks(): + x, y = get_differentiable_vars([-0.1, -0.2], ["x", "y"]) + assert x**2 is not None + assert x**-1 is not None + # negative number raised to fractional power is complex + with pytest.raises(AssertionError): + _ = x**0.2 + # negative number cannot be raised to differentiable power + with pytest.raises(AssertionError): + _ = (-2) ** y + with pytest.raises(AssertionError): + _ = x**y + + +def test_math_funcs_work_with_native_types(): + # python float or int types should be passed through + assert math.isclose(DifferentiableMath.acos(0), math.pi / 2) + (y,) = get_differentiable_vars([1], ["y"]) + res = DifferentiableMath.atan2(y, 0) + assert isinstance(res, VectorHyperDual) + assert math.isclose(res.value, math.pi / 2) + res = DifferentiableMath.atan2(1, 0) + assert isinstance(res, float) + assert math.isclose(DifferentiableMath.pow(0.9, 1.3), math.pow(0.9, 1.3)) + + # however, python's complex type is not supported + with pytest.raises(TypeError, match="Unknown type for addition"): + _ = y + (4 + 1j) + with pytest.raises(TypeError, match="Unknown type for multiplication"): + _ = y * (1 + 2j) + with pytest.raises(TypeError, match="Unknown type for exponentiation"): + _ = y ** (1 + 2j) + + +def test_hyperdual_init_checks(): + with pytest.raises(ValueError): + _ = VectorHyperDual( + 0.1, symbols=["x", "y"], first_der=np.array([0.1, 0.2, 0.3]) + ) + + with pytest.raises(ValueError): + _ = VectorHyperDual( + 0.1, + symbols=["x", "y"], + first_der=np.array([0.1, 0.2]), + second_der=np.zeros(shape=(2, 3)), + ) + + +def test_hyperdual_order(): + symbols = ["x", "y"] + x = VectorHyperDual( + 0.1, + symbols=symbols, + ) + assert x._order == DerivativeOrder.zeroth + x = VectorHyperDual(0.1, symbols=symbols, first_der=np.zeros(2)) + assert x._order == DerivativeOrder.first + # will ignore second derivatives if first is not present + x = VectorHyperDual(0.1, symbols=symbols, second_der=np.zeros((3, 2))) + assert x._order == DerivativeOrder.zeroth + x = VectorHyperDual( + 0.1, + symbols=symbols, + first_der=np.zeros(2), + second_der=np.zeros((2, 2)), + ) + assert x._order == DerivativeOrder.second + + +def test_derivative_not_available(): + x, y = get_differentiable_vars( + [1.0, 2.0], symbols=["x", "y"], deriv_order=DerivativeOrder.first + ) + res = 1 - x**2 + y + assert isinstance(res, VectorHyperDual) + assert math.isclose(res.value, 2) + assert math.isclose(res.differentiate_wrt("x"), -2) + # higher order derivatives are not available + assert res.differentiate_wrt("x", "y") is None + # unknown variables + assert res.differentiate_wrt("z") is None + assert res.differentiate_wrt("y", "z") is None + + # with zero order, only value is present + (x,) = get_differentiable_vars( + [1.0], symbols=["x"], deriv_order=DerivativeOrder.zeroth + ) + res = 1 + x**2 + assert isinstance(res, VectorHyperDual) + assert res.differentiate_wrt("x") is None + + +def test_hyperdual_3d_vector_sanity_checks(): + x, y, z = get_differentiable_vars( + [0.1, 0.2, 0.3], + ["x", "y", "z"], + ) + + with pytest.raises(ValueError): + _ = DifferentiableVector3D([x, y]) + + vec1 = DifferentiableVector3D([x, y, z]) + vec2 = DifferentiableVector3D([x, y, z]) + + with pytest.raises(ValueError): + _ = vec1.dot(2) + + with pytest.raises(AssertionError): + _ = vec1 * vec2 + + assert 2 * vec1 is not None diff --git a/autodE/source/tests/test_opt/test_coordiantes.py b/autodE/source/tests/test_opt/test_coordiantes.py new file mode 100644 index 0000000000000000000000000000000000000000..9e4ee3ae96642a3d19c3717c234564bdfdd891cb --- /dev/null +++ b/autodE/source/tests/test_opt/test_coordiantes.py @@ -0,0 +1,1021 @@ +import itertools + +import pytest +import numpy as np +from .molecules import ( + h2, + methane_mol, + water_mol, + h2o2_mol, + feco5_mol, + cumulene_mol, + acetylene_mol, +) +from autode.utils import work_in_tmp_dir +from autode.atoms import Atom +from autode.species.molecule import Molecule +from autode.values import Angle +from autode.exceptions import CoordinateTransformFailed +from autode.opt.coordinates.internals import ( + PrimitiveInverseDistances, + PIC, + AnyPIC, +) +from autode.opt.coordinates.cartesian import CartesianCoordinates +from autode.opt.coordinates.dic import DIC, DICWithConstraints +from autode.opt.coordinates.primitives import ( + PrimitiveInverseDistance, + PrimitiveDistance, + ConstrainedPrimitiveDistance, + PrimitiveBondAngle, + ConstrainedPrimitiveBondAngle, + PrimitiveDihedralAngle, + PrimitiveImproperDihedral, + PrimitiveLinearAngle, + PrimitiveDummyLinearAngle, + LinearBendType, + CompositeBonds, + ConstrainedCompositeBonds, +) + + +def test_inv_dist_primitives(): + arr = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]]) + + x = CartesianCoordinates(arr) + + inv_dist = PrimitiveInverseDistance(0, 1) + assert np.isclose(inv_dist(x), 0.5) # 1/2.0 = 0.5 Å-1 + + # Check a couple of derivatives by hand + derivs = inv_dist.derivative(x=x) + assert np.isclose(derivs[3 * 0 + 0], 2 * inv_dist(x) ** 3) + assert np.isclose(derivs[3 * 1 + 0], -2 * inv_dist(x) ** 3) + + # Derivatives with respect to zero components + assert np.isclose(derivs[3 * 0 + 1], 0) + + +def test_dist_primitives(): + arr = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]]) + + x = CartesianCoordinates(arr) + + inv_dist = PrimitiveDistance(0, 1) + assert np.isclose(inv_dist(x), 2.0) + + derivs = inv_dist.derivative(x) + assert np.isclose(derivs[3 * 0 + 0], -2 / 2) + assert np.isclose(derivs[3 * 1 + 0], +2 / 2) + + for k in (1, 2): + assert np.isclose(derivs[3 * 1 + k], 0) + + +def test_primitive_equality(): + assert PrimitiveInverseDistance(0, 1) != "a" + assert PrimitiveInverseDistance(0, 1) == PrimitiveInverseDistance(0, 1) + assert PrimitiveInverseDistance(1, 0) == PrimitiveInverseDistance(0, 1) + + +def test_primitives_equality(): + x = CartesianCoordinates(h2().coordinates) + primitives = PrimitiveInverseDistances.from_cartesian(x) + + assert primitives != "a" + assert primitives == PrimitiveInverseDistances.from_cartesian(x) + + # Order does not matter for equality + assert primitives == PrimitiveInverseDistances( + PrimitiveInverseDistance(1, 0) + ) + + +def test_cartesian_coordinates(): + arr = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]]) + + x = CartesianCoordinates(arr) # defaults to Å + assert "cart" in repr(x).lower() + assert x.ndim == 1 + + # Test equality + assert x != "some string" + assert x != arr + + # Can convert to other distance units + assert 0.19 < CartesianCoordinates(arr).to("nm")[3] < 0.21 + + # but not an unsupported unit + with pytest.raises(ValueError): + _ = CartesianCoordinates(arr).to("X") + + +def test_cartesian_coordinates_hessian_update(): + # Simple coordinates with 2 atoms in 3 D + coords = CartesianCoordinates(np.arange(0, 6).reshape((2, 3))) + + with pytest.raises(AssertionError): + coords.update_h_from_cart_h(arr=np.array([])) + + with pytest.raises(AssertionError): + coords.update_h_from_cart_h(arr=np.array([1.0])) + + # Hessian needs to be 6x6 + coords.update_h_from_cart_h(arr=np.eye(6)) + assert coords.h is not None + + # Can set back to None + coords.update_h_from_cart_h(arr=None) + assert coords.h is None + + +def test_cartesian_coordinate_shift_type(): + coords = CartesianCoordinates(np.array([0.0])) + + # Shifting coordinates should retain the type + assert isinstance(coords - 0.1, CartesianCoordinates) + assert isinstance(0.1 - coords, CartesianCoordinates) + assert isinstance(coords + 0.1, CartesianCoordinates) + assert isinstance(0.1 + coords, CartesianCoordinates) + + +def test_hessian_set(): + coords = CartesianCoordinates(np.array([1.0, 2.0])) + + # Hessian and inverse must be NxN matrix, i.e. 2x2 here + for invalid_h in ( + 3, + np.array([1.0]), + np.arange(3), + np.arange(9).reshape(3, 3), + np.arange(6).reshape(2, 3), + ): + with pytest.raises(Exception): + coords.h = invalid_h + + with pytest.raises(Exception): + coords.h_inv = invalid_h + + +def test_hessian_inv(): + coords = CartesianCoordinates(np.array([1.0, 2.0])) + coords.h = 2.0 * np.eye(2) + + expected_h_inv = 0.5 * np.eye(2) + + # Without setting the inverse Hessian it should be + # calculable + assert np.allclose(coords.h_inv, expected_h_inv, atol=1e-10) + + +def test_h_update(): + coords1 = CartesianCoordinates(np.array([1.0, 2.0])) + coords1.g = np.array([0.1, 0.2]) + coords1.h = 2.0 * np.eye(2) + + coords2 = CartesianCoordinates(np.array([1.1, 2.1])) + coords2.g = np.array([0.01, 0.02]) + assert coords2.h is None + + # must define a valid update type + with pytest.raises(RuntimeError): + coords2.update_h_from_old_h(coords1, hessian_update_types=[]) + + from autode.opt.optimisers.hessian_update import BFGSSR1Update + + coords2.update_h_from_old_h(coords1, hessian_update_types=[BFGSSR1Update]) + assert coords2.h is not None + + +def test_cartesian_update_clear(): + arr = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]) + + # Gradients and Hessians are initialized to None + x = CartesianCoordinates(arr) + assert x.g is None and x.h is None + + x.g = np.array([[0.0, 0.0, 0.0], [0.5, 0.0, 0.0]]) + x.h = np.eye(6) + + # Perturbing the coordinates should clear the gradient, as it's no longer + # accurate at these new coordinates + x += np.arange(0, 6, dtype=float) + assert x.g is None and x.h is None + + # or if the values are reassigned individually + x.h = np.eye(6) + x[0] = 0.1 + assert x.h is None + + # or totally with index slicing + x.h = np.eye(6) + x[:] = 0.0 + assert x.h is None + + +def test_basic_dic_properties(): + x = DIC(np.array([1.0])) + assert "dic" in repr(x).lower() + + with pytest.raises(Exception): + _ = x.to("unknown coordinates") + + +def test_dic_constraints(): + mol = water_mol() + mol.constraints.distance = {(0, 1): 1.5} + + pic = AnyPIC.from_species(mol) + x = CartesianCoordinates(mol.coordinates) + q = DICWithConstraints.from_cartesian(x, pic) + assert q.n_constraints == 1 + assert q.g is None and q.h is None + + # with constraints grad or hessian cannot be set directly + with pytest.raises(RuntimeError): + q.g = np.arange(3) + with pytest.raises(RuntimeError): + q.h = np.arange(3) + + q.update_g_from_cart_g(np.random.rand(9)) + q.update_h_from_cart_h(np.random.rand(9, 9)) + # one extra dimension from Lagrange multiplier + assert q.g.shape == (4,) + assert q.h.shape == (4, 4) + + +def test_invalid_pic_construction(): + # Cannot construct some primitives e.g. PrimitiveInverseDistances from non Primitive + # internal coordinates + with pytest.raises(ValueError): + _ = PrimitiveInverseDistances("a") + + +def test_cart_to_dic(): + arr = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]]) + + x = CartesianCoordinates(arr) + + # Should only have 1 internal coordinate + pic = PrimitiveInverseDistances.from_cartesian(x) + assert len(pic) == 1 + + # and not have a B matrix + with pytest.raises(AttributeError): + _ = pic.B + + # Delocalised internals should preserve the single internal coordinate + dics = x.to("dic") + assert isinstance(dics, DIC) + assert len(dics) == 1 + # and store the previous catesian coordinates + assert hasattr(dics, "_x") + # as a copy, so changing the initial x should not change prev_x + assert id(dics._x) != id(x) + + assert dics.inactive_indexes == [] + + x += 0.1 + assert np.allclose(x, np.array([0.1, 0.1, 0.1, 2.1, 0.1, 0.1])) + assert not np.allclose(x, dics._x) + x -= 0.1 + + +def test_cartesian_all_indexes_active(): + arr = np.arange(6) + x = CartesianCoordinates(arr) + assert x.active_indexes == list(range(6)) + assert x.inactive_indexes == list() + + +def test_simple_dic_to_cart(): + arr = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]]) + + # Should be able to transform back to Cartesians + dic = CartesianCoordinates(arr).to("dic") + + x = dic.to("cartesian") + assert np.allclose(CartesianCoordinates(arr), x) + + delta = np.array([0.1]) + assert np.isclose(0.6, (dic + delta)[0], atol=1e-6) + # Updating the DICs should afford cartesian coordinates that are + # ~1.7 Å apart (1/r = 0.6) + dic.iadd(value=delta) + assert dic.shape == (1,) + assert np.isclose(dic[0], 0.6) + + arr_update = dic.to("cart").reshape((2, 3)) + + assert np.isclose( + np.linalg.norm(arr_update[0, :] - arr_update[1, :]), 1.66666, atol=1e-4 + ) + + +def test_methane_cart_to_dic(): + x = CartesianCoordinates(methane_mol().coordinates) + dic = x.to("dic") + assert len(dic) == 9 # 3N-6 for N=5 + + dic.iadd(value=np.array([0.1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0])) + + # Cartesian coordinates should be close to the starting ones + assert np.linalg.norm(x - dic.to("cart")) < 0.5 + + +def test_co2_cart_to_dic(): + arr = np.array( + [ + [-1.31254, 0.34625, -0.00000], + [-0.11672, 0.30964, 0.00000], + [1.07904, 0.27311, 0.00000], + ] + ) + + x = CartesianCoordinates(arr) + dic = x.to("dic") + assert len(dic) == 3 + + +def test_grad_transform_linear(): + k = 1.0 + r0 = 1.0 + + def energy(_x): + """Harmonic potential: E = k(r-r0)^2""" + _x = _x.reshape((-1, 3)) + r = np.linalg.norm(_x[0] - _x[1]) + return 0.5 * k * (r - r0) ** 2 + + def grad(_x): + _x = _x.reshape((-1, 3)) + diff = _x[0, 0] - _x[1, 0] + r = np.linalg.norm(_x[0] - _x[1]) + return np.array( + [ + [k * (r - r0) * diff / r, 0.0, 0.0], + [-k * (r - r0) * diff / r, 0.0, 0.0], + ] + ) + + def num_grad(_x, h=1e-8): + _g = [] + for i in range(len(_x.flatten())): + x_ph = np.array(_x, copy=True) + x_ph[i] += h + + g_i = (energy(x_ph) - energy(_x)) / h + _g.append(g_i) + + return np.array(_g).reshape((2, 3)) + + coords = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]]) + + x = CartesianCoordinates(coords) + + assert np.allclose(num_grad(x), grad(x)) + x.g = grad(coords) + + dic = x.to("dic") + assert dic.shape == (1,) # Only a single distance + assert np.isclose(dic[0], 0.5, atol=1e-6) # 1/r_012 = 0.5 Å + + assert dic.g.shape == (1,) # dE/ds_i has only a single component + + # Determined by hand + assert np.isclose(dic.g[0], -1 / 0.5**2 * grad(coords).flatten()[3]) + + +def test_hess_transform_linear(): + k = 1.0 + coords = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]) + + x = CartesianCoordinates(coords) + + def energy(_x, r0=1): + """Harmonic potential: E = k(r-r0)^2""" + _x = _x.reshape((-1, 3)) + r = np.linalg.norm(_x[0] - _x[1]) + return 0.5 * k * (r - r0) ** 2 + + def hessian(_x): + _x = _x.reshape((-1, 3)) + delta_x = _x[0, 0] - _x[1, 0] + + r = np.linalg.norm(_x[0] - _x[1]) + h_oo = k * (1 - 1 / r + delta_x**2 / r**3) + + _h = np.zeros(shape=(6, 6)) + _h[0, 0] = _h[3, 3] = h_oo + _h[0, 3] = _h[3, 0] = -h_oo + + return _h + + def g_i(_x, i, h=1e-8): + """Numerical graident""" + x_ph = np.array(_x, copy=True) + x_ph[i] += h + return (energy(x_ph) - energy(_x)) / h + + def h_ij(_x, i, j, h=1e-8): + x_ph = np.array(_x, copy=True) + x_ph[j] += h + + return (g_i(x_ph, i=i) - g_i(_x, i=i)) / h + + def num_hess(_x): + _h = np.zeros(shape=(6, 6)) + + for i in range(6): + for j in range(6): + _h[i, j] = h_ij(_x, i, j) + + return _h + + assert np.linalg.norm(hessian(x) - num_hess(x)) < 1e-7 + x.h = hessian(coords) + + dic = x.to("dic") + assert dic.h.shape == (1, 1) # 1x1 internal Hessian + assert np.isclose(dic.h[0, 0], k) # should be ~k + + +def test_hess_positive_definite_no_hessian(): + """Cannot make a None Hessian positive definite""" + + coords = CartesianCoordinates(np.array([1.0, 2.0])) + assert coords.h is None + + with pytest.raises(RuntimeError): + coords.make_hessian_positive_definite() + + +def test_hess_positive_definite_sample(): + coords = CartesianCoordinates(np.array([1.0, 2.0])) + coords.h = np.array([[1.0, 0.1], [0.1, -1.0]]) + + # Have at least one negative eigenvalue + assert sum(lmd < 0 for lmd in np.linalg.eigvalsh(coords.h)) > 0 + + # Should have no negative eigenvalues + coords.make_hessian_positive_definite() + assert sum(lmd < 0 for lmd in np.linalg.eigvalsh(coords.h)) == 0 + + +def test_hess_positive_definite_h2o(): + # h2o = Molecule('water', + # atoms=[Atom('O'), + # Atom('H', -0.8, 0.1), + # Atom('H', 0.8, 0.1)]) + + coords_arr = np.array( + [ + [0.0, -0.01118983, 0.0], + [-0.8, 0.08881017, 0.0], + [0.8, 0.08881017, 0.0], + ] + ).flatten() + + coords = CartesianCoordinates(coords_arr) + + coords.g = np.array( + [ + [7.55890751e-09, 1.78450460e-01, -5.66918063e-09], + [4.77548276e-01, -8.92130384e-02, 0.00000000e00], + [-4.77548270e-01, -8.92130384e-02, 0.00000000e00], + ] + ).flatten() + + coords.h = np.array( + [ + [ + 11.2889, + -0.0, + -0.0, + -5.6443, + 0.8169, + -0.0, + -5.6443, + -0.8169, + 0.0, + ], + [-0.0, -1.528, -0.0, 0.7692, 0.764, 0.0, -0.7692, 0.764, 0.0], + [-0.0, -0.0, -1.783, -0.0, 0.0, 0.8915, 0.0, 0.0, 0.8915], + [ + -5.6435, + 0.7689, + -0.0, + 5.5606, + -0.7928, + 0.0, + 0.0828, + 0.0238, + -0.0, + ], + [ + 0.8167, + 0.7641, + 0.0, + -0.7928, + -0.6295, + -0.0, + -0.0238, + -0.1346, + -0.0, + ], + [-0.0, 0.0, 0.8915, 0.0, -0.0, -0.7441, 0.0, -0.0, -0.1474], + [ + -5.6435, + -0.7689, + 0.0, + 0.0828, + -0.0238, + 0.0, + 5.5606, + 0.7928, + -0.0, + ], + [ + -0.8167, + 0.7641, + 0.0, + 0.0238, + -0.1346, + -0.0, + 0.7928, + -0.6295, + -0.0, + ], + [0.0, 0.0, 0.8915, -0.0, -0.0, -0.1474, -0.0, -0.0, -0.7441], + ] + ) + + # Cannot calculate H^(-1) with a singular Hessian + with pytest.raises(Exception): + _ = coords.h_inv + + coords.make_hessian_positive_definite() + + newton_step = -np.dot(coords.h_inv, coords.g) + + # All the steps should be negative in the gradient + for i in range(9): + assert np.sign(coords.g[i]) != np.sign(newton_step[i]) + + # Step size should be small-ish in all coordinates + assert np.max(np.abs(newton_step)) < 0.2 # Å + + +def test_inplace_subtraction(): + coords = CartesianCoordinates(np.array([1.0])) + + coords -= np.array([0.1]) + assert np.isclose(coords[0], 0.9, atol=1e-8) + + +def test_coords_back_transform_tensor_clear(): + raw_arr = np.arange(6, dtype=float).reshape(2, 3) # 2 atoms + cart = CartesianCoordinates(raw_arr) + + dic = DIC.from_cartesian(cart) + dic.update_g_from_cart_g(np.zeros_like(raw_arr)) + + assert dic.g is not None + assert dic._x.g is not None + + # Updating the positions should clear the gradient array + dic += np.array([0.001]) + assert dic.g is None and dic._x.g is None + + +def test_pic_b_no_primitives(): + c = PIC() + + # Cannot calculate a B matrix with no constituent primitive internals + with pytest.raises(Exception): + c._calc_B(np.arange(6, dtype=float).reshape(2, 3)) + + +def test_pic_add_sanity_checking(): + c = AnyPIC() + # pic add should check for primitive type + c.add(PrimitiveDistance(0, 1)) + with pytest.raises(AssertionError): + c.add(3) + + # pic append is disallowed + with pytest.raises(NotImplementedError, match="Please use PIC.add()"): + c.append(PrimitiveDistance(0, 1)) + + # pic should not allow duplicate coordinates to be added + assert len(c) == 1 + c.add(PrimitiveDistance(1, 0)) + assert len(c) == 1 + + +def test_constrained_distance_satisfied(): + d = ConstrainedPrimitiveDistance(0, 1, value=1.0) + + x = np.array([[0.0, 0.0, 0.0], [0.0, 0.0, 0.5]]) + + assert not d.is_satisfied(x) + + x[1, 2] = 1.0 + assert d.is_satisfied(x) + + +def test_angle_primitive_derivative(): + def numerical_derivative(a, b, h=1e-8): + y = angle(init_coords) + coords = init_coords.copy() + coords[a, int(b)] += h + y_plus = angle(coords) + + return (y_plus - y) / h + + m = water_mol() + init_coords = m.coordinates.copy() + + angle = PrimitiveBondAngle(1, 0, 2) + derivs = angle.derivative(init_coords) + for atom_idx in (0, 1, 2): + for component in (0, 1, 2): + analytic = derivs[3 * atom_idx + component] + + assert np.isclose( + analytic, numerical_derivative(atom_idx, component), atol=1e-6 + ) + + +def test_angle_primitive_equality(): + assert PrimitiveBondAngle(1, 0, 2) == PrimitiveBondAngle(2, 0, 1) + assert PrimitiveBondAngle(1, 0, 2) != PrimitiveBondAngle(1, 2, 0) + + +def test_dihedral_value(): + m = h2o2_mol() + dihedral = PrimitiveDihedralAngle(2, 0, 1, 3) + + assert np.isclose( + dihedral(m.coordinates), Angle(100.8, units="deg").to("rad"), atol=1.0 + ) + + +def test_dihedral_primitive_derivative(): + def numerical_derivative(a, b, h=1e-8): + y = dihedral(init_coords) + coords = init_coords.copy() + coords[a, int(b)] += h + y_plus = dihedral(coords) + + return (y_plus - y) / h + + m = h2o2_mol() + init_coords = m.coordinates.copy() + + dihedral = PrimitiveDihedralAngle(2, 0, 1, 3) + analytic = dihedral.derivative(init_coords) + for atom_idx in (0, 1, 2, 3): + for k in [0, 1, 2]: + numerical = numerical_derivative(atom_idx, k) + assert np.isclose(analytic[3 * atom_idx + k], numerical, atol=1e-6) + + +def test_dihedral_equality(): + assert PrimitiveDihedralAngle(2, 0, 1, 3) == PrimitiveDihedralAngle( + 2, 0, 1, 3 + ) + assert PrimitiveDihedralAngle(2, 0, 1, 3) == PrimitiveDihedralAngle( + 3, 1, 0, 2 + ) + + +def test_composite_bonds_equality(): + a = CompositeBonds(bonds=[(1, 2), (2, 3)], coeffs=[0.5, 1.2]) + b = CompositeBonds(bonds=[(1, 2), (2, 3)], coeffs=[0.5, 1.2]) + c = CompositeBonds(bonds=[(0, 5), (2, 4)], coeffs=[0.5, 1.2]) + d = CompositeBonds(bonds=[(1, 2), (2, 3)], coeffs=[0.1, 1.2]) + assert a == b + assert a != c # different bonds + assert a != d # different coefficient + + +def test_linear_angle(): + acetylene = Molecule( + atoms=[ + Atom("C", 0.35540, -0.20370, -0.44810), + Atom("C", -0.37180, 0.21470, 0.40200), + Atom("H", 1.01560, -0.60550, -1.23530), + Atom("H", -0.99920, 0.59450, 1.15720), + ] + ) + x = CartesianCoordinates(acetylene.coordinates) + angle = PrimitiveDummyLinearAngle(0, 1, 3, LinearBendType.BEND) + assert angle._vec_r is None + _ = angle(x) + assert angle._vec_r is not None + old_r_vec = angle._vec_r + # the dummy atom should not change after the first call + _ = angle(x + 0.05) + _ = angle(x - 0.07) + assert angle._vec_r is old_r_vec + + axis_vec = np.array(np.array(angle._vec_r._data) - x.reshape(-1, 3)[1]) + m_n_vec = acetylene.coordinates[0] - acetylene.coordinates[1] + assert abs(np.dot(axis_vec, m_n_vec)) < 0.001 + + # check that the linear bond complement does not have the same value + angle2 = PrimitiveDummyLinearAngle(0, 1, 3, LinearBendType.COMPLEMENT) + assert angle != angle2 + assert not np.isclose(angle(x), angle2(x), rtol=1e-3) + + # for linear angle, swapping the end points changes the definition + angle3 = PrimitiveDummyLinearAngle(3, 1, 0, LinearBendType.BEND) + assert angle3 != angle + + +def test_primitives_consistent_with_mol_values(): + # test that the primitive values are the same as the mol.distance etc. + h2o2 = h2o2_mol() + coords = h2o2.coordinates + dist = PrimitiveDistance(0, 1) + assert np.isclose(dist(coords), h2o2.distance(0, 1), rtol=1e-8) + invdist = PrimitiveInverseDistance(1, 2) + assert np.isclose(invdist(coords), 1 / h2o2.distance(1, 2), rtol=1e-8) + ang = PrimitiveBondAngle(0, 2, 1) + assert np.isclose(ang(coords), h2o2.angle(0, 2, 1), rtol=1e-8) + dihedral = PrimitiveDihedralAngle(2, 0, 1, 3) + assert np.isclose(dihedral(coords), h2o2.dihedral(2, 0, 1, 3), rtol=1e-8) + ic = CompositeBonds([(0, 1), (0, 2)], [0.3, 0.7]) + mol_val = 0.3 * h2o2.distance(0, 1) + 0.7 * h2o2.distance(0, 2) + assert np.isclose(mol_val, ic(coords)) + + +# fmt: off +extra_mols = [ + Molecule( + atoms=[ + Atom("C", 0.63365, 0.11934, -0.13163), + Atom("C", -0.63367, -0.11938, 0.13153), + Atom("H", 1.08517, 1.07993, 0.05600), + Atom("H", -1.08517, -1.07984, -0.05599), + ] + ), + Molecule( + atoms=[ + Atom("C", 0.63365, 0.11934, -0.13163), + Atom("C", -0.63367, -0.11938, 0.13153), + Atom("H", 1.28230, -0.63391, -0.54779), + Atom("H", -1.08517, -1.07984, -0.05599), + ] + ), # for testing dihedral derivatives over zero + Molecule( + atoms=[ + Atom("C", 0.35540, -0.20370, -0.44810), + Atom("C", -0.37180, 0.21470, 0.40200), + Atom("H", 1.01560, -0.60550, -1.23530), + Atom("H", -0.99920, 0.59450, 1.15720), + ] + ), + feco5_mol(), # for testing linear angles + h2o2_mol(), +] + +test_mols = [ + h2o2_mol(), h2o2_mol(), water_mol(), + water_mol(), water_mol(), *extra_mols +] +test_prims = [ + PrimitiveDihedralAngle(2, 0, 1, 3), PrimitiveBondAngle(0, 2, 1), + PrimitiveBondAngle(1, 0, 2), PrimitiveDistance(0, 1), + PrimitiveInverseDistance(0, 1), PrimitiveDihedralAngle(2, 0, 1, 3), + PrimitiveDihedralAngle(2, 0, 1, 3), + PrimitiveDummyLinearAngle(0, 1, 3, LinearBendType.BEND), + PrimitiveLinearAngle(2, 3, 4, 8, LinearBendType.BEND), + CompositeBonds([(0, 1), (0, 2)], [1, 1]), +] +# fmt: on + + +@pytest.mark.parametrize("mol,prim", list(zip(test_mols, test_prims))) +def test_primitive_first_derivs(mol, prim): + init_coords = CartesianCoordinates(mol.coordinates) + init_prim = prim(init_coords) + + def numerical_first_deriv(coords, h=1e-8): + coords = coords.flatten() + derivs = np.zeros_like(coords) + for i in range(coords.shape[0]): + coords[i] += h + derivs[i] = (prim(coords) - init_prim) / h + coords[i] -= h + return derivs + + analytic = prim.derivative(init_coords) + numeric = numerical_first_deriv(init_coords) + assert np.allclose(analytic, numeric, atol=1e-6) + + +@pytest.mark.parametrize("mol,prim", list(zip(test_mols, test_prims))) +def test_primitve_second_deriv(mol, prim): + init_coords = CartesianCoordinates(mol.coordinates) + init_first_der = prim.derivative(init_coords) + + def numerical_second_deriv(coords, h=1e-8): + coords = coords.flatten() + derivs = np.zeros((coords.shape[0], coords.shape[0])) + for i in range(coords.shape[0]): + coords[i] += h + derivs[i] = (prim.derivative(coords) - init_first_der) / h + coords[i] -= h + return derivs + + analytic = prim.second_derivative(init_coords) + # second derivative matrix should be symmetric + assert np.allclose(analytic, analytic.T) + numeric = numerical_second_deriv(init_coords) + assert np.allclose(analytic, numeric, atol=1e-6) + + +def test_repr(): + """Test that each primitive has a representation""" + + prims = [ + PrimitiveInverseDistance(0, 1), + PrimitiveDistance(0, 1), + ConstrainedPrimitiveDistance(0, 1, value=1e-3), + PrimitiveBondAngle(1, 0, 2), + ConstrainedPrimitiveBondAngle(1, 0, 2, value=1.0), + PrimitiveDihedralAngle(0, 1, 2, 3), + PrimitiveLinearAngle(0, 1, 2, 3, LinearBendType.BEND), + PrimitiveLinearAngle(0, 1, 2, 3, LinearBendType.COMPLEMENT), + PrimitiveDummyLinearAngle(0, 1, 2, LinearBendType.BEND), + PrimitiveImproperDihedral(0, 1, 2, 3), + CompositeBonds(bonds=[(1, 2), (2, 3)], coeffs=[1, 1]), + ConstrainedCompositeBonds([(1, 2), (2, 3)], [1, 1], 0.2), + ] + + for p in prims: + assert repr(p) is not None + + +def test_dic_large_step_allowed_unconverged_back_transform(): + x = CartesianCoordinates(water_mol().coordinates) + dic = DIC.from_cartesian(x) + + # unconverged IBT is allowed by default + dic_unconverged = dic + 1.0 * np.ones(shape=(len(dic),)) + new_x = dic_unconverged.to("cartesian") + + # DIC transform should have moved the cartesian coordinates + assert not np.allclose(new_x, x) + + # should raise exception if unconverged IBT is disallowed + dic.allow_unconverged_back_transform = False + with pytest.raises(CoordinateTransformFailed): + _ = dic + 1.0 * np.ones(shape=(len(dic),)) + + +def test_constrained_angle_delta(): + q = ConstrainedPrimitiveBondAngle(1, 0, 2, value=np.pi) + mol = water_mol() + theta = mol.angle(1, 0, 2) + x = CartesianCoordinates(mol.coordinates) + + assert np.isclose(q.delta(x), theta - np.pi) + + +def test_constrained_angle_equality(): + a = ConstrainedPrimitiveBondAngle(1, 0, 2, value=np.pi) + b = ConstrainedPrimitiveBondAngle(2, 0, 1, value=np.pi) + + assert a == b + + b._theta0 = 0.0 + assert a != b + + +def test_dics_cannot_be_built_with_incomplete_primitives(): + x = CartesianCoordinates(methane_mol().coordinates) + primitives = PIC(PrimitiveDistance(0, 1)) + + with pytest.raises(RuntimeError): + _ = DIC.from_cartesian(x=x, primitives=primitives) + + +def test_pic_generation_linear_angle_ref(): + # Fe(CO)5 with linear Fe-C-O bonds + m = feco5_mol() + pic = AnyPIC.from_species(m) + + # check that there are no duplicates + assert not any(ic1 == ic2 for ic1, ic2 in itertools.combinations(pic, r=2)) + # check that linear bends use reference atoms, not dummy + assert not any(isinstance(ic, PrimitiveDummyLinearAngle) for ic in pic) + assert PrimitiveLinearAngle(4, 3, 2, 8, LinearBendType.BEND) in pic + # for C-Fe-C, only one out-of-plane dihedral should be present + assert PrimitiveImproperDihedral(3, 5, 2, 1) in pic + assert sum(isinstance(ic, PrimitiveDihedralAngle) for ic in pic) == 1 + # check degrees of freedom = 3N - 6 + x = m.coordinates.flatten() + assert np.linalg.matrix_rank(pic.get_B(x)) == 3 * m.n_atoms - 6 + + +def test_pic_generation_linear_angle_dummy(): + # acetylene molecule + mol = acetylene_mol() + pic = AnyPIC.from_species(mol) + + # there should not be any usual bond angles + assert not any(isinstance(ic, PrimitiveBondAngle) for ic in pic) + # there should not be any linear angles with reference atom + assert not any(isinstance(ic, PrimitiveLinearAngle) for ic in pic) + # there should be linear angles with dummy + assert any(isinstance(ic, PrimitiveDummyLinearAngle) for ic in pic) + + # degrees of freedom = 3N - 5 for linear molecules + x = mol.coordinates.flatten() + assert np.linalg.matrix_rank(pic.get_B(x)) == 3 * mol.n_atoms - 5 + + +@work_in_tmp_dir() +def test_pic_generation_disjoint_graph(): + # the algorithm should fully connect the graph + xyz_string = ( + "16\n\n" + "C -0.00247 1.65108 0.05872\n" + "C 1.19010 1.11169 0.27709\n" + "C 1.58519 -0.30014 0.31049\n" + "C 0.05831 -1.54292 -0.45110\n" + "C -1.18798 -1.04262 0.13551\n" + "C -1.28206 0.99883 -0.23631\n" + "H -0.07432 2.73634 0.08639\n" + "H 2.01755 1.78921 0.47735\n" + "H 1.70503 -0.70916 1.30550\n" + "H 2.40398 -0.55376 -0.34855\n" + "H 0.44229 -2.48695 -0.08638\n" + "H 0.15289 -1.41865 -1.51944\n" + "H -1.25410 -1.13318 1.21833\n" + "H -2.09996 -1.35918 -0.36715\n" + "H -2.09462 1.29055 0.41495\n" + "H -1.56001 1.00183 -1.28217\n" + ) + with open("diels_alder_complex.xyz", "w") as fh: + fh.write(xyz_string) + + mol = Molecule("diels_alder_complex.xyz") + assert not mol.graph.is_connected + pic = AnyPIC.from_species(mol) + + # shortest bond is between 4, 5 which should also generate angle, torsion + assert PrimitiveDistance(4, 5) in pic + assert PrimitiveBondAngle(3, 4, 5) in pic + assert PrimitiveBondAngle(4, 5, 0) in pic + assert PrimitiveDihedralAngle(3, 4, 5, 0) in pic + + # the other distance between fragments is 2, 3 which should not be connected + assert PrimitiveDistance(2, 3) not in pic + assert PrimitiveBondAngle(1, 2, 3) not in pic + # check degrees of freedom = 3N - 6 + x = mol.coordinates.flatten() + assert np.linalg.matrix_rank(pic.get_B(x)) == 3 * mol.n_atoms - 6 + + # if the bond between 2, 3 is made into a constraint, it will generate angles + mol.constraints.distance = {(2, 3): mol.distance(2, 3)} + pic = AnyPIC.from_species(mol) + assert ConstrainedPrimitiveDistance(2, 3, mol.distance(2, 3)) in pic + assert PrimitiveBondAngle(1, 2, 3) in pic + + +def test_pic_generation_chain_dihedrals(): + # extra dihedrals are needed for ends of linear chains like allene + cumulene = cumulene_mol() + pic = AnyPIC.from_species(cumulene) + + assert PrimitiveDihedralAngle(5, 3, 4, 8) in pic + assert PrimitiveDihedralAngle(6, 3, 4, 7) in pic + assert PrimitiveDihedralAngle(8, 4, 3, 6) in pic + assert PrimitiveDihedralAngle(7, 4, 3, 6) in pic + + # check that the 3N-6 degrees of freedom are maintained + x = cumulene.coordinates.flatten() + assert np.linalg.matrix_rank(pic.get_B(x)) == 3 * cumulene.n_atoms - 6 + + +def test_pic_generation_square_planar(): + ptcl4 = Molecule( + atoms=[ + Atom("Pt", -0.1467, -0.2594, -0.0294), + Atom("Cl", -0.4597, -2.5963, -0.0523), + Atom("Cl", 2.1804, -0.5689, -0.2496), + Atom("Cl", -2.4738, 0.0501, 0.1908), + Atom("Cl", 0.1663, 2.0776, -0.0066), + ], + charge=-2, + ) + + # for sq planar, out-of-plane dihedrals are needed to have + # all degrees of freedom + pic = AnyPIC.from_species(ptcl4) + x = ptcl4.coordinates.flatten() + assert np.linalg.matrix_rank(pic.get_B(x)) == 3 * ptcl4.n_atoms - 6 diff --git a/autodE/source/tests/test_opt/test_crfo.py b/autodE/source/tests/test_opt/test_crfo.py new file mode 100644 index 0000000000000000000000000000000000000000..d7a26eec6c64ec74b7211c4089b884e243e947c2 --- /dev/null +++ b/autodE/source/tests/test_opt/test_crfo.py @@ -0,0 +1,429 @@ +import pytest +import numpy as np + +import autode.values as val +import autode.opt.coordinates.primitives as prim +from autode.species.molecule import Molecule +from autode.atoms import Atom +from autode.methods import XTB +from autode.opt.coordinates.internals import PIC +from autode.opt.optimisers.crfo import CRFOptimiser +from autode.opt.coordinates import CartesianCoordinates, DICWithConstraints +from autode.opt.coordinates.primitives import ( + PrimitiveDihedralAngle, + ConstrainedCompositeBonds, +) +from autode.utils import work_in_tmp_dir +from .molecules import h2o2_mol, acetylene_mol, feco5_mol, cumulene_mol +from ..testutils import requires_working_xtb_install + + +def crfo_coords(molecule): + optimiser = CRFOptimiser(maxiter=1, conv_tol="normal") + optimiser._species = molecule + optimiser._build_internal_coordinates() + + return optimiser._coords + + +def water_molecule(oh_distance=1): + """Water molecule with a constraint""" + + m = Molecule( + name="water", + charge=0, + mult=1, + atoms=[ + Atom("O", -0.00110, 0.36310, 0.00000), + Atom("H", -0.82500, -0.18190, 0.00000), + Atom("H", 0.82610, -0.18120, 0.00000), + ], + ) + m.constraints.distance = {(0, 1): val.Distance(oh_distance, "Å")} + + return m + + +def test_coordinate_setup(): + mol = water_molecule() + dist_consts = mol.constraints.distance + assert (0, 1) in dist_consts and (1, 0) in dist_consts + + opt = CRFOptimiser(maxiter=1, conv_tol="normal") + + with pytest.raises(RuntimeError): + # Cannot set coordinates without a species + opt._build_internal_coordinates() + + opt._species = mol + opt._build_internal_coordinates() + assert opt._coords.n_constraints == 1 + + # Ensure that the final DIC comprises a single primitive, which is the + # first (inverse) distance populated in the coordinates + assert np.allclose(opt._coords.U[:, 2], np.array([1.0, 0.0, 0.0])) + + # Initial lagrangian multiplier is close to zero, which is the last + # component in the optimisation space + assert np.isclose(opt._coords._lambda[0], 0.0) + + +def crfo_water_coords(): + return crfo_coords(molecule=water_molecule()) + + +def test_adding_invalid_step(): + s = crfo_water_coords() + # the added step must be the length of the coordinates plus + # one lagragne multiplier + invalid_step = np.ones(shape=(5,)) + + with pytest.raises(AssertionError): + s += invalid_step + + +def test_simple_gradient_update(): + coords = crfo_water_coords() + + cartesian_g = np.array([[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]]) + coords.update_g_from_cart_g(cartesian_g) + + # dL/dλ = -C(x) = -(r_OH^-1 - r_ideal^-1) + assert np.isclose(coords.g[3], -coords.primitives[0].delta(coords._x)) + + +def test_simple_hessian_update(): + coords = crfo_water_coords() + + cartesian_h = 9.9 * np.eye(9) + coords.update_h_from_cart_h(cartesian_h) + + assert coords.h.shape == (4, 4) + + assert not np.isclose(coords.h[0, 0], 0.0) # d^2L/ds_0ds_0 + assert not np.isclose(coords.h[0, 1], 0.0) # d^2L/ds_0ds_1 + + assert np.isclose(coords.h[3, 3], 0.0) # d^2L/dλ^2 + assert np.isclose(coords.h[2, 3], -1.0) # d^2L/dλ^2 + + # Hessian needs to be symmetric + assert np.allclose(coords.h.T, coords.h) + + +def test_primitive_projection_discard(): + optimiser = CRFOptimiser(maxiter=1, conv_tol="loose") + optimiser._species = water_molecule() + + # Current distance that will be constrained + r_initial = optimiser._species.distance(0, 1) + + x = CartesianCoordinates(optimiser._species.coordinates) + optimiser._build_internal_coordinates() + s = optimiser._coords + assert len(s) == 3 + + # Shift on the first couple of DIC but nothing on the final one + s += np.array([0.03, -0.07, 0.0, 0.0]) + + def r(_x): + return np.linalg.norm(_x[:3] - _x[3:6]) + + # Should not change value of the 'removed' coordinate + assert np.isclose(r(s.to("cartesian")), r_initial, atol=1e-10) + + +def test_sanitised_zero_length_step(): + """Should be able to update with a null step""" + + optimiser = CRFOptimiser(conv_tol="loose", maxiter=1) + optimiser._coords = CartesianCoordinates(np.array([])) + optimiser._take_step_within_trust_radius(np.array([])) + + +@requires_working_xtb_install +@work_in_tmp_dir() +def test_xtb_opt_with_distance_constraint(): + water = Molecule( + name="water", + charge=0, + mult=1, + atoms=[ + Atom("O", -0.00110, 0.36310, 0.00000), + Atom("H", -0.82500, -0.18190, 0.00000), + Atom("H", 0.82610, -0.18120, 0.00000), + ], + ) + + water.constraints.distance = {(0, 1): val.Distance(1.1, units="Å")} + + assert np.isclose(water.distance(0, 1), 0.99, atol=0.01) + + CRFOptimiser.optimise(species=water, method=XTB(), conv_tol="loose") + + # Optimisation should generate an O-H distance *very* close to 1.1 Å + assert np.isclose(water.distance(0, 1).to("Å"), 1.1, atol=1e-4) + + +def test_step_c2h3(): + m = Molecule( + atoms=[ + Atom("C", 0.63365, 0.11934, -0.13163), + Atom("C", -0.63367, -0.11938, 0.13153), + Atom("H", 1.28230, -0.63391, -0.54779), + Atom("H", 1.08517, 1.07993, 0.05600), + Atom("H", -1.08517, -1.07984, -0.05599), + ] + ) + m.constraints.distance = {(0, 1): m.distance(0, 1)} + + coords = crfo_coords(m) + + # Should be able to add an arbitrary vector to the coordinates + coords += np.random.uniform(-0.1, 0.1, size=coords.raw.shape) + + +def test_baker1997_example(): + c2h3f = Molecule( + atoms=[ + Atom("C", 0.061684, 0.673790, 0.0), + Atom("C", 0.061684, 0.726210, 0.0), + Atom("F", 1.174443, 1.331050, 0.0), + Atom("H", 0.927709, 1.173790, 0.0), + Atom("H", 0.927709, 1.226210, 0.0), + Atom("H", 0.804342, 1.226210, 0.0), + ] + ) + + r1 = prim.ConstrainedPrimitiveDistance(0, 1, value=1.5) + r2 = prim.ConstrainedPrimitiveDistance(3, 4, value=2.5) + theta = prim.ConstrainedPrimitiveBondAngle( + 0, 1, 5, value=val.Angle(123.0, "º").to("rad") + ) + + pic = PIC(r1, r2, theta) + for pair in ((2, 0), (3, 0), (4, 1), (5, 1)): + pic.add(prim.PrimitiveDistance(*pair)) + + for triple in ( + (0, 1, 3), + (0, 1, 3), + (0, 2, 3), + (1, 4, 0), + (1, 5, 0), + (1, 4, 5), + ): + pic.add(prim.PrimitiveBondAngle(*triple)) + + for quadruple in ((4, 1, 0, 2), (4, 1, 0, 3), (5, 1, 0, 2), (5, 1, 0, 3)): + pic.add(prim.PrimitiveDihedralAngle(*quadruple)) + + dic = DICWithConstraints.from_cartesian( + x=CartesianCoordinates(c2h3f.coordinates), primitives=pic + ) + assert len(dic) == 10 + + +@requires_working_xtb_install +@work_in_tmp_dir() +def test_crfo_with_dihedral(): + mol = h2o2_mol() + constrained_distance = mol.distance(0, 1) + 0.1 + mol.constraints.distance = {(0, 1): constrained_distance} + + CRFOptimiser.optimise(species=mol, method=XTB(), maxiter=10) + + assert np.isclose(mol.distance(0, 1), constrained_distance, atol=1e-4) + + +@requires_working_xtb_install +@work_in_tmp_dir() +def test_xtb_opt_with_two_distance_constraint(): + water = Molecule( + name="water", + charge=0, + mult=1, + atoms=[ + Atom("O", -0.00110, 0.36310, 0.00000), + Atom("H", -0.82500, -0.18190, 0.00000), + Atom("H", 0.82610, -0.18120, 0.00000), + ], + ) + + water.constraints.distance = { + (0, 1): val.Distance(1.0, units="Å"), + (0, 2): val.Distance(1.0, units="Å"), + } + + opt = CRFOptimiser(maxiter=1, conv_tol="loose", init_alpha=0.1) + opt._species = water + opt._method = XTB() + opt._initialise_run() + + # Moving the angle should preserve the distances + s = opt._coords + np.array([0.1, 0.0, 0.0, 0.0, 0.0]) # append multiplier + x = s.to("cart").reshape((3, 3)) + for i, j in ((0, 1), (0, 2)): + assert np.isclose( + np.linalg.norm(x[i, :] - x[j, :]), water.distance(i, j) + ) + + opt._step() + x = opt._coords.to("cart").reshape((3, 3)) + for i, j in ((0, 1), (0, 2)): + assert np.abs(np.linalg.norm(x[i, :] - x[j, :]) - 1.0) < np.abs( + water.distance(i, j) - 1.0 + ) + + opt = CRFOptimiser(maxiter=10, conv_tol="loose", init_alpha=0.1) + opt.run(species=water, method=XTB()) + assert opt.converged + + for pair in ((0, 1), (0, 2)): + assert np.isclose(water.distance(*pair).to("Å"), 1.0, atol=1e-2) + + +def test_step_with_180degree_dihedrals(): + ethane = Molecule( + atoms=[ + Atom("C", -6.05284, 0.86485, 0.00000), + Atom("C", -4.56258, 1.15982, 0.00000), + Atom("H", -6.48013, 1.08394, 1.00120), + Atom("H", -6.22501, -0.20494, -0.24263), + Atom("H", -6.56172, 1.49618, -0.75857), + Atom("H", -4.05370, 0.52850, 0.75857), + Atom("H", -4.39041, 2.22962, 0.24263), + Atom("H", -4.13530, 0.94074, -1.00120), + ] + ) + + ds = [ + -1.900e-04, + 1.00e-05, + -1.90e-04, + 2.00e-05, + 2.000e-04, + 3.749e-02, + -1.800e-04, + -8.00e-05, + 1.80e-04, + 1.80e-04, + 1.340e-03, + 8.700e-04, + -2.880e-03, + -1.20e-04, + -6.40e-04, + -1.80e-04, + 3.300e-04, + 9.863e-02, + ] + + # Should be able to take a step without any warnings + dic = crfo_coords(ethane) + dic.allow_unconverged_back_transform = False + dic += ds + + +def test_linear_dihedrals_are_removed(): + allene = Molecule( + atoms=[ + Atom("C", 0.35540, -0.20370, -0.44810), + Atom("C", -0.37180, 0.21470, 0.40200), + Atom("H", 1.01560, -0.60550, -1.23530), + Atom("H", -0.99920, 0.59450, 1.15720), + ] + ) + + dic = crfo_coords(allene) + assert not any( + isinstance(q, PrimitiveDihedralAngle) for q in dic.primitives + ) + + +@requires_working_xtb_install +@work_in_tmp_dir() +def test_optimise_linear_molecule(): + mol = acetylene_mol() + # the two H-C-C angles are almost linear + assert val.Angle(170, "deg") < mol.angle(0, 1, 3) < val.Angle(176, "deg") + assert val.Angle(170, "deg") < mol.angle(2, 0, 1) < val.Angle(176, "deg") + opt = CRFOptimiser(maxiter=10, conv_tol="loose") + opt.run(mol, XTB()) + assert opt.converged + assert mol.angle(0, 1, 3) > val.Angle(179, "deg") + assert mol.angle(2, 0, 1) > val.Angle(179, "deg") + + +@requires_working_xtb_install +@work_in_tmp_dir() +def test_optimise_linear_bend_with_ref(): + mol = feco5_mol() + # the Fe-C-O angle are manually deviated + assert val.Angle(170, "deg") < mol.angle(2, 3, 4) < val.Angle(176, "deg") + # large molecule so allow few iters, no need to converge fully + opt = CRFOptimiser(maxiter=10, conv_tol="loose") + opt.run(mol, XTB()) + assert mol.angle(2, 3, 4) > val.Angle(178, "deg") + + +@requires_working_xtb_install +@work_in_tmp_dir() +def test_optimise_chain_dihedrals(): + mol = cumulene_mol() + assert abs(mol.dihedral(6, 3, 4, 8)) < val.Angle(40, "deg") + opt = CRFOptimiser(maxiter=20, conv_tol="loose") + opt.run(mol, XTB()) + # 5-C chain, should be close to 90 degrees + assert abs(mol.dihedral(6, 3, 4, 8)) > val.Angle(85, "deg") + assert abs(mol.dihedral(6, 3, 4, 8)) < val.Angle(95, "deg") + + +@requires_working_xtb_install +@work_in_tmp_dir() +def test_composite_bond_constraint(): + atoms = [ + Atom("C", -2.6862, 1.0780, -0.1640), + Atom("C", -2.1836, -0.0798, -0.5820), + Atom("C", -0.5315, 2.2123, -0.0294), + Atom("H", -1.1416, -0.3055, -0.5232), + Atom("C", -1.8773, 2.2239, 0.0534), + Atom("H", 0.0331, 1.3028, -0.0181), + Atom("H", -0.4693, 2.6248, -1.9811), + Atom("C", -0.2061, 1.6165, -2.2424), + Atom("H", -2.8152, -0.9212, -0.7994), + Atom("H", -2.3965, 3.1639, 0.1692), + Atom("H", 0.8400, 1.4387, -2.4301), + Atom("C", -1.1022, 0.6635, -2.5738), + Atom("H", -3.7553, 1.2122, -0.0856), + Atom("H", 0.0433, 3.1121, 0.1002), + Atom("H", -2.1548, 0.8243, -2.5510), + Atom("C", -0.6762, -0.6020, -3.1972), + Atom("O", 0.4285, -0.9110, -3.5228), + Atom("H", -1.5372, -1.3068, -3.3795), + ] + # asymmetric Diels-Alder (butadiene + acrolein) + mol = Molecule(atoms=atoms) + constr = ConstrainedCompositeBonds( + bonds=[(1, 11), (2, 7)], coeffs=[1, 1], value=4.6 + ) + # current sum of distances ~ 4.7 A, difference ~ 0.07 + assert np.isclose(mol.distance(1, 11), 2.385, rtol=1e-4) + assert np.isclose(mol.distance(2, 7), 2.315, rtol=1e-4) + opt = CRFOptimiser(maxiter=10, conv_tol="loose", extra_prims=[constr]) + opt.run(mol, method=XTB()) + # sum of distances should be ~4.6 A + assert np.isclose(mol.distance(1, 11) + mol.distance(2, 7), 4.6, rtol=1e-4) + # difference should be much higher, as TS is asymmetric + assert mol.distance(1, 11) - mol.distance(2, 7) > 0.1 + + +def test_trust_radius_limits(): + import autode.opt.optimisers.crfo + + max_lim = autode.opt.optimisers.crfo.MAX_TRUST + opt = CRFOptimiser(maxiter=10, conv_tol="loose", init_trust=max_lim + 0.1) + assert np.isclose(opt.alpha, max_lim) + min_lim = autode.opt.optimisers.crfo.MIN_TRUST + opt = CRFOptimiser( + maxiter=10, conv_tol="loose", init_trust=min_lim - 0.001 + ) + assert np.isclose(opt.alpha, min_lim) diff --git a/autodE/source/tests/test_opt/test_dimer.py b/autodE/source/tests/test_opt/test_dimer.py new file mode 100644 index 0000000000000000000000000000000000000000..da0caed4e93b1878e65a5e5c7b0affdde8aed7d8 --- /dev/null +++ b/autodE/source/tests/test_opt/test_dimer.py @@ -0,0 +1,269 @@ +import pytest +import numpy as np +from autode.atoms import Atom +from autode.species.molecule import Molecule +from autode.methods import XTB +from autode.values import MWDistance +from autode.opt.coordinates.dimer import DimerCoordinates, DimerPoint +from autode.opt.optimisers.dimer import Dimer +from autode.utils import work_in_tmp_dir +from .molecules import methane_mol +from ..testutils import requires_working_xtb_install + + +def _single_atom_dimer_coords(): + return DimerCoordinates(np.arange(9, dtype=float).reshape(3, 3)) + + +def test_dimer_coord_init(): + # Dimer coordinates must be a 3xn matrix of the mid and two end points + with pytest.raises(ValueError): + _ = DimerCoordinates(np.array([0.0, 0.1])) + + with pytest.raises(ValueError): + _ = DimerCoordinates(np.arange(2).reshape(2, 2)) + + coords = _single_atom_dimer_coords() + assert not coords.did_rotation + assert not coords.did_translation + + assert coords != "a" + + +def test_dimer_coord_mol_init(): + mol1 = Molecule() + mol2 = Molecule(atoms=[Atom("H")], mult=2) + + # Dimer coordinates must be created from two species with the same + # atomic composition + with pytest.raises(ValueError): + _ = DimerCoordinates.from_species(mol1, mol2) + + # Dimer coordinates are concatenated cartesian coordinates + coords = DimerCoordinates.from_species(mol2, mol2) + assert coords.shape == (3, 3) + + assert coords.g is None + assert coords.h is None + + +def test_dimer_coord_init_polyatomic(): + mol1 = Molecule(atoms=[Atom("H"), Atom("H", x=1.0)]) + mol2 = Molecule(atoms=[Atom("H", 0.1), Atom("H", x=1.1)]) + + coords = DimerCoordinates.from_species(mol1, mol2) + assert coords.shape == (3, 6) + + # Coordinates are mass weighted, so not precisely the below values + assert np.allclose( + coords.x0, np.array([0.05, 0.0, 0.0, 1.05, 0.0, 0.0]), atol=0.1 + ) + + assert np.allclose( + coords.x1, np.array([0.0, 0.0, 0.0, 1.0, 0.0, 0.0]), atol=0.1 + ) + + assert np.allclose( + coords.x2, np.array([0.1, 0.0, 0.0, 1.1, 0.0, 0.0]), atol=0.1 + ) + + # Gradient has not been evaluated + with pytest.raises(Exception): + _ = coords.g0 + + +def test_dimer_invalid_update(): + coords = _single_atom_dimer_coords() + + # Cannot update the gradient from an individual cartesian gradient, as the + # point on the dimer must be specified + with pytest.raises(Exception): + coords.update_g_from_cart_g(np.zeros(3)) + + # Currently there is no Hessian to be updated with, so anything goes + coords.update_h_from_cart_h(None) + assert coords.h is None + + # Not a valid conversion dimer to individual cartesian + with pytest.raises(Exception): + _ = coords.to("cartesian") + + +def test_repr(): + assert "dimer" in repr(_single_atom_dimer_coords()).lower() + + +def test_mass_weighting_no_masses(): + coords = _single_atom_dimer_coords() + + with pytest.raises(Exception): + _ = coords.x_at(DimerPoint.midpoint, mass_weighted=False) + + with pytest.raises(Exception): + _ = coords.set_g_at( + DimerPoint.midpoint, np.zeros(3), mass_weighted=False + ) + + +def test_dimer_init_zero_distance(): + a = Molecule(atoms=[Atom("H")], mult=2) + + dimer = Dimer(maxiter=10, coords=DimerCoordinates.from_species(a, a)) + dimer._species = a + + # Should raise an exception if there is no distance between the end points + with pytest.raises(RuntimeError): + dimer._initialise_run() + + +def test_dimer_coords_phi_set(): + coords = _single_atom_dimer_coords() + + # Phi must be an angle with defined units + with pytest.raises(ValueError): + coords.phi = "a" + + with pytest.raises(ValueError): + coords.phi = 0.0 + + +class Dimer2D(Dimer): + r""" + Dimer on a 2D PES + + E = x^2 - y^2 + + which generates the classic saddle point:: + + __________________ + | low | + | | + |high TS high| + | | + | low | + ------------------ + """ + + __test__ = False + + def _update_gradient_at(self, point) -> None: + """E = x^2 - y^2 --> (dE/dx)_y = 2x ; (dE/dy)_x = -2y""" + if point == DimerPoint.midpoint: + x, y = self._coords.x0 + else: + x, y = self._coords[int(point), :] + + self._coords.set_g_at(point, np.array([2.0 * x, -2.0 * y])) + return None + + def _initialise_run(self) -> None: + self._coords.g = np.zeros(shape=(3, 2)) + + for point in DimerPoint: + self._update_gradient_at(point) + + return None + + +@work_in_tmp_dir() +def test_dimer_2d(): + arr = np.array( + [ + [np.nan, np.nan], + [-0.5, -0.5], + [0.0, 0.5], + ] # x0 (midpoint) # x1 (left) + ) # x2 (right) + + dimer = Dimer2D( + maxiter=100, coords=DimerCoordinates(arr), init_alpha=MWDistance(0.5) + ) + + # Check the midpoint of the dimer is positioned correctly + assert np.allclose(dimer._coords.x0, np.array([-0.25, 0.0]), atol=1e-10) + + # check the distance between the endf points + assert np.isclose( + dimer._coords.delta, np.sqrt((-0.5) ** 2 + 1**2) / 2.0, atol=1e-10 + ) + + # optimise the rotation, should be able to be very accurate + dimer._history.open("dimer_test.zip") + dimer._initialise_run() + dimer._optimise_rotation() + + # and check that the rotation does not change the distance between the end + # points of the dimer + + for iteration in dimer._history: + assert np.isclose(iteration.delta, dimer._history[0].delta, atol=1e-1) + + # final iteration should have a change in rotation angle below the + assert abs(dimer._dc_dphi) < 0.2 + + # Do single translation step + dimer._translate() + + # then optimise the translation + while dimer._history.final.dist > 1e-2: + dimer._translate() + + # TS is located at (0, 0) in the (x, y) plane + assert np.allclose( + np.linalg.norm(dimer._coords.x0), np.zeros(2), atol=1e-3 + ) + + +@requires_working_xtb_install +@work_in_tmp_dir() +def test_dimer_sn2(): + left_point = Molecule( + name="sn2_left", + charge=-1, + mult=1, + solvent_name="water", + atoms=[ + Atom("F", -5.09333, 4.39680, 0.09816), + Atom("Cl", -0.96781, 4.55705, -0.06369), + Atom("C", -3.36921, 4.46281, 0.03034), + Atom("H", -3.24797, 3.87380, -0.85688), + Atom("H", -3.17970, 4.00237, 0.97991), + Atom("H", -3.27773, 5.52866, -0.04735), + ], + ) + + right_point = Molecule( + name="sn2_right", + charge=-1, + mult=1, + solvent_name="water", + atoms=[ + Atom("F", -5.38530, 4.38519, 0.10942), + Atom("Cl", -0.93502, 4.55732, -0.06547), + Atom("C", -3.05723, 4.47536, 0.01839), + Atom("H", -3.26452, 3.87898, -0.84787), + Atom("H", -3.19779, 4.00624, 0.97190), + Atom("H", -3.29590, 5.51839, -0.04586), + ], + ) + + coords = DimerCoordinates.from_species(left_point, right_point) + dimer = Dimer(maxiter=50, coords=coords, ratio_rot_iters=10) + + ts = left_point.new_species("ts") + dimer.run(species=ts, method=XTB(), n_cores=1) + + ts.coordinates = dimer._history.final.x_at( + DimerPoint.midpoint, mass_weighted=False + ) + ts.print_xyz_file(filename="tmp.xyz") + + assert dimer.iteration > 1 + # assert dimer._history.final.phi.to('degrees') < 10 + assert dimer.converged + + +def test_dimer_optimise_no_coordinates(): + # Cannot use a dimer optimiser on a single species, unlike other optimisers + with pytest.raises(Exception): + Dimer.optimise(species=methane_mol(), method=XTB()) diff --git a/autodE/source/tests/test_opt/test_hessian_update.py b/autodE/source/tests/test_opt/test_hessian_update.py new file mode 100644 index 0000000000000000000000000000000000000000..be6952a19dc0ac4cb190347ea79616786ce955c5 --- /dev/null +++ b/autodE/source/tests/test_opt/test_hessian_update.py @@ -0,0 +1,341 @@ +import os +import pytest +import numpy as np +from ..testutils import work_in_zipped_dir +from autode.opt.coordinates import CartesianCoordinates +from autode.opt.optimisers.hessian_update import ( + BFGSUpdate, + BFGSPDUpdate, + SR1Update, + NullUpdate, + BofillUpdate, + FlowchartUpdate, + BFGSSR1Update, +) + +here = os.path.dirname(os.path.abspath(__file__)) + + +def update_improves_hessian(updater, guess, true): + return updater.conditions_met and ( + np.linalg.norm(updater.updated_h - true) < np.linalg.norm(guess - true) + ) + + +def update_improves_inv_hessian(updater, guess, true): + return updater.conditions_met and ( + np.linalg.norm(updater.updated_h_inv - true) + < np.linalg.norm(guess - true) + ) + + +def check_hessian_update(update_type, grad, h_true): + for pair in [(0.3, 0.3), (0.5, 0.5)]: + x = np.array(pair) + + h = h_true(*x) + h_inv = np.linalg.inv(h) + + h_guess = np.eye(2) # I_2 = I_2^(-1) + h_inv_guess = np.eye(2) + + for increment in (0.1, 0.2, 0.3): + x_new = x - increment + updater = update_type( + s=x_new - x, + y=grad(*x_new) - grad(*x), + h=h_guess, + h_inv=h_inv_guess, + ) + + assert update_improves_hessian(updater, guess=h_guess, true=h) + assert update_improves_inv_hessian( + updater, guess=h_inv_guess, true=h_inv + ) + + +def test_quadratic_hessian_update(): + """E = x^2 + y^2""" + + for update_type in (BFGSUpdate, SR1Update): + check_hessian_update( + update_type, + grad=lambda x, y: 2.0 * np.array([x, y]), + h_true=lambda x, y: 2.0 * np.eye(2), + ) + + +def test_quadratic2_hessian_update(): + """E = x^2 + y^2 + xy/2""" + + for update_type in (BFGSUpdate, SR1Update): + check_hessian_update( + update_type, + grad=lambda x, y: np.array([2 * x + y / 2, 2 * y + x / 2]), + h_true=lambda x, y: np.array([[2.0, 0.5], [0.5, 2.0]]), + ) + + +def test_polynomial_hessian_update(): + """E = x^5 + y^3 + xy^2/2""" + + # Hessian for more complex 2D surface fails to improve with an SR1 update + check_hessian_update( + BFGSUpdate, + grad=lambda x, y: np.array( + [5 * x**4 + y**2 / 2, 3 * y**2 + x * y] + ), + h_true=lambda x, y: np.array([[20 * x**3, y], [y, 6 * y + x]]), + ) + + +def test_null_update(): + updater = NullUpdate(h=10.0 * np.eye(4)) + + # Null update has no conditions on update + assert updater.conditions_met + + # and just returns the hessian back + assert np.allclose(updater.updated_h, 10.0 * np.eye(4)) + + +def test_updater_class(): + updater = NullUpdate() + + # Cannot update without either an initial H or H^(-1) + with pytest.raises(RuntimeError): + _ = updater.updated_h_inv + + with pytest.raises(RuntimeError): + _ = updater.updated_h + + +def test_bofill_update(): + """Notation follows https://aip.scitation.org/doi/pdf/10.1063/1.1515483""" + + G_prev = np.array([[1.0, 0.1], [0.1, 1.0]]) # initial approximate Hessian + + dx = np.array([0.01, 0.03]) + dg = np.array([-0.02, -0.06]) + + updater = BofillUpdate(h=G_prev, s=dx, y=dg) + + dg_Gdx = dg - np.dot(G_prev, dx) + + G_MS = G_prev + (np.outer(dg_Gdx, dg_Gdx) / np.dot(dg_Gdx, dx)) + + G_PSB = ( + G_prev + + ((np.outer(dg_Gdx, dx) + np.outer(dx, dg_Gdx)) / np.dot(dx, dx)) + - ( + (np.dot(dx, dg) - np.dot(dx, np.dot(G_prev, dx))) + * np.outer(dx, dx) + / np.dot(dx, dx) ** 2 + ) + ) + + phi = 1.0 - ( + np.dot(dx, dg_Gdx) ** 2 / (np.dot(dx, dx) * np.dot(dg_Gdx, dg_Gdx)) + ) + + G_bofill = (1.0 - phi) * G_MS + phi * G_PSB + + assert np.allclose(updater.updated_h, G_bofill, atol=1e-10) + + +def test_repr_and_strings(): + """ + Test that the hessian update types have defined representation + and strings + """ + + for update_type in ( + BFGSUpdate, + BFGSPDUpdate, + SR1Update, + NullUpdate, + BofillUpdate, + BFGSSR1Update, + FlowchartUpdate, + ): + assert update_type().__repr__() is not None + assert str(update_type()) is not None + + +class BFGSPDUpdateNoUpdate(BFGSPDUpdate): + @property + def _updated_h(self) -> np.ndarray: + return self.h + + @property + def _updated_h_inv(self) -> np.ndarray: + return self.h_inv + + +@pytest.mark.parametrize(("eigval", "expected"), ([-1.0, False], [1.0, True])) +def test_bfgs_pd_update(eigval, expected): + h = np.array([[1.0, 0.0], [0.0, eigval]]) + + updater = BFGSPDUpdateNoUpdate( + min_eigenvalue=0.1, h=h, s=np.array([0.1, 0.1]), y=np.array([0.1, 0.1]) + ) + + # Needs to satisfy the secant equation + assert updater.y.dot(updater.s) > 0 + assert updater.conditions_met == expected + + +def test_update_fails_if_no_suited_scheme(): + coords1 = CartesianCoordinates(np.array([0.1, 0.1])) + coords1.g = np.array([0.1, 0.1]) + coords1.h = np.array([[1.0, 0.0], [0.0, -1]]) + coords2 = CartesianCoordinates(np.array([0.2, 0.2])) + coords2.g = np.array([0.2, 0.2]) + with pytest.raises(RuntimeError): + coords2.update_h_from_old_h(coords1, [BFGSPDUpdateNoUpdate]) + + +@work_in_zipped_dir(os.path.join(here, "data", "hessians.zip")) +def test_bfgs_and_bfgssr1_update_water(): + h = np.loadtxt("water_cart_hessian0.txt") + + x0 = [-0.0011, 0.3631, -0.0, -0.825, -0.1819, -0.0, 0.8261, -0.1812, 0.0] + g0 = [ + -0.0026315238924962724, + 0.02788091552485463, + 3.5360899212838047e-16, + -0.0498982397982622, + -0.013121966502035046, + -3.3717488622744373e-16, + 0.05252976369075942, + -0.014758949022819396, + -1.6434105900929746e-17, + ] + + x1 = [ + -8.629582978216805e-05, + 0.37325030920738983, + 0.0, + -0.7760070560089669, + -0.1873002044203907, + 0.0, + 0.7760933518387491, + -0.1859501047869992, + 0.0, + ] + g1 = [ + 0.0008070291147249597, + -0.007905970009730014, + -1.5862959782497162e-16, + 0.0004597293468636392, + 0.003663285355006926, + 1.8209233374357918e-16, + -0.001266758461588599, + 0.004242684654723089, + -2.3462735918607574e-17, + ] + + for update_type in [BFGSUpdate, BFGSSR1Update]: + updater = update_type( + h=h, s=np.array(x1) - np.array(x0), y=np.array(g1) - np.array(g0) + ) + + new_updated_h = updater.updated_h + new_true_h = np.loadtxt("water_cart_hessian1.txt") + + assert np.sum(np.abs(new_updated_h - new_true_h)) < np.sum( + np.abs(h - new_true_h) + ) + + +def test_flowchart_update_all_components_work(): + # fictitious data + h = np.array([[1.0, 0.01], [0.01, 1.0]]) + y = np.array([0.01, 0.03]) + s = np.array([0.02, 0.06]) + z = y - h @ s + # this fulfils SR1 criteria + assert z.dot(s) / (np.linalg.norm(z) * np.linalg.norm(s)) < -0.1 + updater = FlowchartUpdate(h=h, s=s, y=y) + h_new = updater.updated_h + sr1_updater = SR1Update(h=h, s=s, y=y) + assert np.allclose(h_new, sr1_updater.updated_h) + + y = np.array([0.04, 0.06]) + z = y - h @ s + # not SR1 + assert z.dot(s) / (np.linalg.norm(z) * np.linalg.norm(s)) > -0.1 + # fulfils BFGS criteria + assert np.dot(y, s) / (np.linalg.norm(y) * np.linalg.norm(s)) > 0.1 + updater = FlowchartUpdate(h=h, s=s, y=y) + h_new = updater.updated_h + bfgs_updater = BFGSUpdate(h=h, s=s, y=y) + assert np.allclose(h_new, bfgs_updater.updated_h) + + h = np.array([[-1.0, 0.1], [0.01, -1.0]]) + y = np.array([0.01, 0.03]) + s = np.array([-0.02, -0.06]) + z = y - h @ s + updater = FlowchartUpdate(h=h, s=s, y=y) + h_new = updater.updated_h + # not BFGS + assert np.dot(y, s) / (np.linalg.norm(y) * np.linalg.norm(s)) < 0.1 + # not SR1 + assert np.dot(z, s) / (np.linalg.norm(z) * np.linalg.norm(s)) > -0.1 + # this gives Powell Symmetric Broyden update + z = z.reshape(-1, 1) + s = s.reshape(-1, 1) # cast into column forms + delta_psb = (s @ z.T + z @ s.T) / (s.T @ s) + delta_psb -= (s.T @ z) * (s @ s.T) / (s.T @ s) ** 2 + assert np.allclose(h_new, h + delta_psb) + + h = np.array([[1.0, 0.1], [0.1, 1.0]]) + s = np.array([-0.02, -0.06]) + updater = FlowchartUpdate(h=h, s=s, y=y) + h_new = updater.updated_h + # not BFGS + assert np.dot(y, s) / (np.linalg.norm(y) * np.linalg.norm(s)) < 0.1 + # this uses SR1 update + sr1_updater = SR1Update(h=h, y=y, s=s) + assert np.allclose(h_new, sr1_updater.updated_h) + + # no conditions for Flowchart updates + assert updater.conditions_met + # inverse should be available from private attribute + assert updater._updated_h_inv is not None + + +def test_bfgs_sr1_hybrid_update(): + h = np.array([[1.0, 0.1], [0.1, 1.0]]) # initial approximate Hessian + + y = np.array([0.01, 0.03]) + s = np.array([-0.02, -0.06]) + + updater = BFGSSR1Update(h=h, y=y, s=s) + h_new = updater.updated_h + + delta_h = h_new - h + + delta_bfgs_h = BFGSUpdate(h=h, y=y, s=s).updated_h - h + delta_sr1_h = SR1Update(h=h, y=y, s=s).updated_h - h + + # definition according to Farkas, Schlegel, J Chem Phys, 111, 1999 + phi_bofill = np.dot(-y + h @ s, s) ** 2 + phi_bofill /= np.dot(-y + h @ s, -y + h @ s) * np.dot(s, s) + sqrt_phi = np.sqrt(phi_bofill) + + assert np.allclose( + delta_h, sqrt_phi * delta_sr1_h + (1 - sqrt_phi) * delta_bfgs_h + ) + + # no conditions for BFGS-SR1 hybrid + assert updater.conditions_met + # inverse should be available from private attribute + assert updater._updated_h_inv is not None + + +def test_hessian_requires_at_least_one_index(): + h = np.eye(3) + s = y = np.zeros(3) + with pytest.raises(ValueError): + updater = BFGSUpdate(h=h, s=s, y=y, subspace_idxs=[]) diff --git a/autodE/source/tests/test_opt/test_opt.py b/autodE/source/tests/test_opt/test_opt.py new file mode 100644 index 0000000000000000000000000000000000000000..3cbea6da7f995574f61254bc3a07f44088023b4e --- /dev/null +++ b/autodE/source/tests/test_opt/test_opt.py @@ -0,0 +1,562 @@ +import copy +import os +import zipfile + +import pytest +import numpy as np + +from autode.methods import XTB +from autode.calculations.types import CalculationType +from autode.values import GradientRMS, PotentialEnergy +from autode.species.molecule import Molecule +from autode.hessians import Hessian +from autode.utils import work_in_tmp_dir +from ..testutils import requires_working_xtb_install +from .molecules import h2, methane_mol, h_atom +from .setup import Method +from autode.utils import NumericStringDict +from autode.opt.coordinates import CartesianCoordinates +from autode.opt.optimisers.base import ( + OptimiserHistory, + NullOptimiser, + ConvergenceParams, +) +from autode.opt.optimisers.steepest_descent import ( + CartesianSDOptimiser, + DIC_SD_Optimiser, +) + + +def sample_cartesian_optimiser(): + return CartesianSDOptimiser( + maxiter=1, conv_tol=ConvergenceParams(abs_d_e=0.1, rms_g=0.1) + ) + + +def test_optimiser_construct(): + # Optimiser needs a Species + with pytest.raises(ValueError): + sample_cartesian_optimiser().run(species=None, method=XTB()) + + # also a method + with pytest.raises(ValueError): + sample_cartesian_optimiser().run(species=methane_mol(), method=None) + + # Optimiser needs valid arguments + with pytest.raises( + ValueError, match="must be able to run at least one step" + ): + _ = CartesianSDOptimiser(maxiter=0, conv_tol="normal") + + with pytest.raises( + ValueError, match="Value of abs_d_e should be positive" + ): + _ = CartesianSDOptimiser( + maxiter=1, conv_tol=ConvergenceParams(abs_d_e=-0.1, rms_g=0.1) + ) + + with pytest.raises(ValueError, match="Value of rms_g should be positive"): + _ = CartesianSDOptimiser( + maxiter=1, conv_tol=ConvergenceParams(abs_d_e=0.1, rms_g=-0.1) + ) + + with pytest.raises(ValueError, match="Unknown preset convergence"): + _ = CartesianSDOptimiser(maxiter=1, conv_tol="unknown") + + # should be able to set convergence through setter + opt = CartesianSDOptimiser(maxiter=1, conv_tol="loose") + opt.conv_tol = "normal" + with pytest.raises(ValueError, match="Unknown preset convergence"): + opt.conv_tol = "unknown" + + # at least RMS g convergence criteria has to be defined + with pytest.raises( + ValueError, match="RMS gradient criteria has to be defined" + ): + _ = CartesianSDOptimiser( + maxiter=1, conv_tol=ConvergenceParams(abs_d_e=0.1) + ) + + +def test_optimiser_convergence(caplog): + opt = CartesianSDOptimiser( + maxiter=10, + conv_tol=ConvergenceParams( + abs_d_e=0.01, rms_g=0.01, max_g=0.01, rms_s=0.01, max_s=0.01 + ), + ) + coords1 = CartesianCoordinates(np.arange(6, dtype=float)) + opt._species = Molecule(smiles="N#N") + opt._coords = coords1 + opt._coords.g = np.random.random(6) + opt._coords.e = PotentialEnergy(0.1, "Ha") + + # grad + energy < 1/2 + step is < * 3 + coords2 = coords1 + 0.02 + coords2.g = np.array([0.004] * 6) + coords2.e = PotentialEnergy(0.1 - 0.004, "Ha") + opt._coords = coords2 + with caplog.at_level("WARNING"): + assert opt.converged + assert "Overachieved gradient and energy" in caplog.text + assert "reasonable convergence on step size" in caplog.text + caplog.clear() + # grad ~ 1/10, dE < *1.5, step < * 2 + coords2 = coords1 + 0.014 + coords2.g = np.array([0.0009] * 6) + coords2.e = PotentialEnergy(0.1 - 0.015) + opt._history._memory[-1] = coords2 + with caplog.at_level("WARNING"): + assert opt.converged + assert "Gradient is one order of magnitude below" in caplog.text + assert "other parameter(s) are almost converged" + caplog.clear() + # step achieved, grad ~ 0.7, dE < * 3 + coords2 = coords1 + 0.009 + coords2.g = np.array([0.006] * 6) + coords2.e = PotentialEnergy(0.1 - 0.025) + opt._history._memory[-1] = coords2 + with caplog.at_level("WARNING"): + assert opt.converged + assert "Everything except energy has been converged" in caplog.text + assert "Reasonable convergence on energy" in caplog.text + + +def test_initialise_species_and_method(): + optimiser = sample_cartesian_optimiser() + + # Species and method need to be valid + with pytest.raises(ValueError): + optimiser._initialise_species_and_method(species=None, method=None) + + with pytest.raises(ValueError): + optimiser._initialise_species_and_method(species="a", method=None) + + +def test_coords_set(): + optimiser = sample_cartesian_optimiser() + + # Internal set of coordinates must be an instance of OptCoordinate + with pytest.raises(ValueError): + optimiser._coords = "a" + + +def test_history(): + optimiser = sample_cartesian_optimiser() + assert optimiser.iteration < 1 + assert len(optimiser._history) < 1 + + # Cannot get the final set of coordinates without any history + with pytest.raises(IndexError): + _ = optimiser._history.final + + # or the ones before that + with pytest.raises(IndexError): + _ = optimiser._history.penultimate + + +@work_in_tmp_dir() +@requires_working_xtb_install +def test_xtb_h2_cart_opt(): + mol = h2() + CartesianSDOptimiser.optimise(mol, method=XTB(), maxiter=50) + + # Optimised H-H distance is ~0.7 Å + assert np.isclose(mol.distance(0, 1), 0.777, atol=0.1) + + +@work_in_tmp_dir() +@requires_working_xtb_install +def test_xtb_h2_cart_opt_2(): + optimiser = CartesianSDOptimiser( + maxiter=2, conv_tol=ConvergenceParams(abs_d_e=1e-3, rms_g=0.01) + ) + optimiser._species = h2() + optimiser._coords = CartesianCoordinates(optimiser._species.coordinates) + optimiser._species.single_point(XTB()) + optimiser._coords.e = optimiser._species.energy + + assert not optimiser.converged + + # Should not converge in only two steps + optimiser.run(method=XTB(), species=h2()) + assert not optimiser.converged + # a trajectory file should be written + assert os.path.isfile("h2_opt_trj.zip") + # cleaning up optimiser will remove trajectory + optimiser.clean_up() + assert not os.path.isfile("h2_opt_trj.zip") + + +@work_in_tmp_dir() +@requires_working_xtb_install +def test_xtb_h2_dic_opt(): + # In DICs we can use a much larger step size + optimiser = DIC_SD_Optimiser( + step_size=2.5, + maxiter=10, + conv_tol=ConvergenceParams(abs_d_e=1e-4, rms_g=0.01), + ) + + mol = h2() + # Should optimise fast, in only a few steps + optimiser.run(species=mol, method=XTB()) + + assert optimiser.converged + assert optimiser.iteration < 10 + assert np.isclose(mol.distance(0, 1), 0.77, atol=0.1) + + +class HarmonicPotentialOptimiser(CartesianSDOptimiser): + def _update_gradient_and_energy(self): + self._species.coordinates = self._coords.to("cart") + r = self._species.distance(0, 1) + self._coords.e = self._species.energy = (r - 2.0) ** 2 + self._coords.g = np.array([-0.01, 0.0, 0.0, 0.01, 0.0, 0.0]) + + +@work_in_tmp_dir() +def test_callback_function(): + mol = h2() + + def func(coords, m=None): + m.print_xyz_file(filename="tmp.xyz") + assert os.path.exists("tmp.xyz") + + optimiser = HarmonicPotentialOptimiser( + maxiter=1, + callback=func, + callback_kwargs={"m": mol}, + conv_tol=ConvergenceParams(rms_g=0.1, abs_d_e=0.1), + ) + + optimiser.run(species=mol, method=Method()) + + +@work_in_tmp_dir() +def test_last_energy_change_with_no_steps(): + mol = h2() + optimiser = HarmonicPotentialOptimiser( + maxiter=2, conv_tol=ConvergenceParams(abs_d_e=999, rms_g=999) + ) + + optimiser.run(mol, method=Method()) + assert optimiser.converged + assert optimiser.last_energy_change < 1 + + +def test_value_extraction_from_string(): + value = 99.9 + s = f"E = {value}" # " =" is implied + assert np.isclose(NumericStringDict(s)["E"], value) + + +@work_in_tmp_dir() +@requires_working_xtb_install +def test_optimisation_is_possible_with_single_atom(): + mol = h_atom() + CartesianSDOptimiser.optimise(mol, method=XTB(), maxiter=2) + assert mol.energy is None + + +class ConvergedHarmonicPotentialOptimiser(CartesianSDOptimiser): + @property + def converged(self) -> bool: + return True + + +class UnconvergedHarmonicPotentialOptimiser(CartesianSDOptimiser): + @property + def converged(self) -> bool: + return False + + +def test_last_energy_change_less_than_two_steps(): + optimiser = ConvergedHarmonicPotentialOptimiser( + maxiter=2, conv_tol=ConvergenceParams(abs_d_e=999, rms_g=999) + ) + + coords = CartesianCoordinates(np.zeros(1)) + coords.e = 0 + coords.g = np.zeros_like(coords) + optimiser._coords = coords + + assert optimiser.converged + assert np.isclose(optimiser.last_energy_change, 0.0) + + optimiser.__class__ = UnconvergedHarmonicPotentialOptimiser + assert not optimiser.converged + assert not np.isfinite(optimiser.last_energy_change) + + +class HessianInTesting(Hessian): + """Hessian with a different class, used for testing""" + + +@work_in_tmp_dir() +@requires_working_xtb_install +def test_hessian_is_not_recalculated_if_present(): + mol = h2() + xtb = XTB() + + optimiser = CartesianSDOptimiser(maxiter=1, conv_tol="loose") + optimiser.run(species=mol, method=xtb, n_cores=1) + + mol.calc_hessian(method=xtb) + mol.hessian.__class__ = HessianInTesting + + # If the Hessian calculation is skipped then the class will be retained + optimiser._update_hessian_gradient_and_energy() + assert mol.hessian.__class__ == HessianInTesting + + +@work_in_tmp_dir() +@requires_working_xtb_install +def test_multiple_optimiser_saves_overrides_not_append(): + optimiser = CartesianSDOptimiser(maxiter=2, conv_tol="loose") + optimiser.run(method=XTB(), species=h2(), name="tmp.zip") + + assert os.path.isfile("tmp.zip") + with zipfile.ZipFile("tmp.zip") as file: + names = file.namelist() + + old_n_coords = sum([1 for name in names if name.startswith("coords_")]) + + optimiser = CartesianSDOptimiser(maxiter=2, conv_tol="loose") + optimiser.run(method=XTB(), species=h2(), name="tmp.zip") + # the file "tmp.zip" should be overwritten by new optimiser + with zipfile.ZipFile("tmp.zip") as file: + names = file.namelist() + + n_coords = sum([1 for name in names if name.startswith("coords_")]) + assert old_n_coords == n_coords + + +@work_in_tmp_dir() +def test_optimiser_plotting_sanity_checks(caplog): + mol = Molecule(smiles="N#N") + opt = CartesianSDOptimiser(maxiter=10, conv_tol="loose") + coords1 = CartesianCoordinates(mol.coordinates) + coords1.e = PotentialEnergy(0.1, "Ha") + coords1.update_g_from_cart_g( + np.array([0.01, 0.02, 0.05, 0.06, 0.03, 0.07]) + ) + opt._coords = coords1 + opt._species = mol + assert opt.iteration == 0 + assert not opt.converged + # plotting does not work if less than 2 points + with caplog.at_level("WARNING"): + opt.plot_optimisation(filename="test-plot.pdf") + assert not os.path.isfile("test-plot.pdf") + assert "Less than 2 points, cannot draw optimisation" in caplog.text + + opt._coords = coords1.copy() + opt._coords.e = PotentialEnergy(0.0, "Ha") + assert not opt.converged + # either rms_g or energy plot has to be requested + with caplog.at_level("ERROR"): + opt.plot_optimisation("test-plot.pdf", False, False) + assert not os.path.isfile("test-plot.pdf") + assert "Must plot either energies or RMS gradients" in caplog.text + with caplog.at_level("WARNING"): + opt.plot_optimisation("test-plot.pdf", plot_energy=True) + assert os.path.isfile("test-plot.pdf") + assert "Optimisation is not converged, drawing a plot" in caplog.text + + +@work_in_tmp_dir() +def test_optimiser_print_geometries(caplog): + mol = Molecule(smiles="C=C", name="mymolecule") + coords1 = CartesianCoordinates(mol.coordinates) + opt = CartesianSDOptimiser(maxiter=20, conv_tol="loose") + opt._coords = coords1 + # cannot print geom without species + with pytest.raises(AssertionError): + opt.print_geometries() + + opt._species = mol + assert opt.iteration == 0 + with caplog.at_level("WARNING"): + opt.print_geometries() + assert "Optimiser did no steps, not saving .xyz" in caplog.text + assert not os.path.isfile("mymolecule_opt.trj.xyz") + opt._coords = coords1.copy() + opt.print_geometries() + assert os.path.isfile("mymolecule_opt.trj.xyz") + old_size = os.path.getsize("mymolecule_opt.trj.xyz") + # running should overwrite the geometries + opt.print_geometries() + new_size = os.path.getsize("mymolecule_opt.trj.xyz") + assert old_size == new_size + + +def _get_4_random_coordinates(): + coords_list = [] + for _ in range(4): + coords_list.append(CartesianCoordinates(np.random.rand(6))) + return coords_list + + +@work_in_tmp_dir() +def test_optimiser_history_storage(): + coords1, coords2, coords3, coords4 = _get_4_random_coordinates() + + hist = OptimiserHistory(maxlen=3) + # cannot close without opening a file + with pytest.raises(RuntimeError): + hist.close() + hist.open("test.zip") + assert os.path.isfile("test.zip") + # cannot reinitialise + with pytest.raises(RuntimeError, match="cannot initialise again"): + hist.open("test.zip") + # cannot add something that is not coordinates + with pytest.raises(ValueError, match="must be OptCoordinates"): + hist.add("x") + hist.add(coords1) + hist.add(coords2) + hist.add(coords3) + # nothing should be on disk yet + assert len(hist) == 3 and hist._n_stored == 0 + # now last coord is put on disk + hist.add(coords4) + assert len(hist) == 4 and hist._n_stored == 1 + assert len(hist._memory) == 3 + hist.close() + # now should be 3 more stored on disk + assert len(hist) == 4 and hist._n_stored == 4 + # adding new coords is forbidden + with pytest.raises(RuntimeError): + hist.add(coords1) + # iterate through the history in reverse + iterator = reversed(hist) + last = next(iterator) + before_last = next(iterator) + assert np.allclose(last, coords4) and np.allclose(before_last, coords3) + # clean up + hist.clean_up() + assert not os.path.isfile("test.zip") + + +@work_in_tmp_dir() +def test_optimiser_history_getitem(): + coords0, coords1, coords2, coords3 = _get_4_random_coordinates() + hist = OptimiserHistory(maxlen=2) + hist.open("test.zip") + hist.add(coords0) + hist.add(coords1) + hist.add(coords2) + hist.add(coords3) + assert np.allclose(hist[0], coords0) # from disk + hist[0].e = PotentialEnergy(0.001, "Ha") + assert hist[0].e is None # cannot modify disk + assert np.allclose(hist[2], coords2) # from memory + assert hist[2].e is None + hist[2].e = PotentialEnergy(0.01, "Ha") + assert np.isclose(hist[2].e, 0.01) + # slicing does not work + with pytest.raises(NotImplementedError): + _ = hist[0:1] + # can only have integer indices + with pytest.raises(ValueError): + _ = hist["x"] + with pytest.raises(IndexError): + _ = hist[4] + with pytest.raises(IndexError): + _ = hist[-5] + # if no disk backend, then old coordinates are lost + hist_nodisk = OptimiserHistory(maxlen=2) + hist_nodisk.add(coords0) + hist_nodisk.add(coords1) + hist_nodisk.add(coords2) + assert hist_nodisk[0] is None + assert hist_nodisk._n_stored == 0 + + +@work_in_tmp_dir() +def test_optimiser_history_reload(): + coords0, coords1, coords2, coords3 = _get_4_random_coordinates() + hist = OptimiserHistory(maxlen=2) + hist.open("savefile") + assert os.path.isfile("savefile.zip") # extension added + hist.add(coords0) + hist.add(coords1) + hist.add(coords2) + hist.add(coords3) + hist.close() + hist = None + with pytest.raises(FileNotFoundError, match="test.zip does not exist"): + _ = OptimiserHistory.load("test") + with open("test.zip", "w") as fh: + fh.write("abcd") + # error if file is not zip + with pytest.raises(ValueError, match="not a valid trajectory"): + _ = OptimiserHistory.load("test") + # error if file does not have the autodE opt header + with zipfile.ZipFile("new.zip", "w") as file: + fh = file.open("testfile", "w") + fh.write("abcd".encode()) + fh.close() + with pytest.raises(ValueError, match="not an autodE trajectory"): + _ = OptimiserHistory.load("new.zip") + hist = OptimiserHistory.load("savefile") + assert np.allclose(hist[-1], coords3) + assert np.allclose(hist[-2], coords2) + assert np.allclose(hist[-3], coords1) + + +@work_in_tmp_dir() +def test_optimiser_history_reload_works_with_one(): + coords0 = CartesianCoordinates(np.random.rand(6)) + hist = OptimiserHistory(maxlen=2) + + hist.open("savefile") + # adding None will not do anything + hist.add(None) + assert len(hist) == 0 + # just add one more coordinate + hist.add(coords0) + hist.close() + assert os.path.isfile("savefile.zip") + hist = OptimiserHistory.load("savefile") + assert len(hist) == 1 + assert np.allclose(hist[0], coords0) + assert hist[0] is hist[-1] + + +@work_in_tmp_dir() +def test_optimiser_history_save_load_params(): + hist = OptimiserHistory() + # cannot save or load without having file backing + with pytest.raises(RuntimeError, match="File not opened"): + hist.save_opt_params({"maxiter": 10}) + with pytest.raises(RuntimeError, match="File not opened"): + hist.get_opt_params() + hist.open("test.zip") + # cannot load as it is not available + with pytest.raises(FileNotFoundError, match="not found!"): + hist.get_opt_params() + # can now save and load + hist.save_opt_params({"maxiter": 10, "gtol": 1e-3}) + params = hist.get_opt_params() + assert len(params) == 2 + assert params["maxiter"] == 10 and params["gtol"] == 1e-3 + # cannot save again - overwrite not allowed + with pytest.raises(FileExistsError, match="already stored"): + hist.save_opt_params({"maxiter": 10}) + + +def test_mocked_method(): + method = Method() + assert method.implements(CalculationType.energy) + assert repr(method) is not None # just needs to be implemented + + +def test_null_optimiser_methods(): + optimiser = NullOptimiser() + optimiser.run() + # run does nothing + + with pytest.raises(RuntimeError): + _ = optimiser.final_coordinates diff --git a/autodE/source/tests/test_opt/test_opt_utils.py b/autodE/source/tests/test_opt/test_opt_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..c0507ef929f1460c07ce59f58d9786a15c491e56 --- /dev/null +++ b/autodE/source/tests/test_opt/test_opt_utils.py @@ -0,0 +1,84 @@ +from autode import Molecule +from autode.utils import work_in_tmp_dir +from autode.opt.coordinates import CartesianCoordinates +from autode.bracket.imagepair import _calculate_engrad_for_species +from autode.methods import XTB +from autode.opt.optimisers.utils import TruncatedTaylor, Polynomial2PointFit +from scipy.optimize import minimize +import numpy as np +from numpy.polynomial import Polynomial +from ..testutils import requires_working_xtb_install + + +@requires_working_xtb_install +@work_in_tmp_dir() +def test_truncated_taylor_surface(): + mol = Molecule(smiles="CCO") + mol.calc_hessian(method=XTB()) + coords = CartesianCoordinates(mol.coordinates) + coords.update_g_from_cart_g(mol.gradient) + coords.update_h_from_cart_h(mol.hessian) + coords.make_hessian_positive_definite() + + # for positive definite hessian, minimum of taylor surface would + # be a simple Newton step + minim = coords - (np.linalg.inv(coords.h) @ coords.g) + + # minimizing surface should give the same result + surface = TruncatedTaylor(coords, coords.g, coords.h) + res = minimize( + method="CG", + fun=surface.value, + x0=np.array(coords), + jac=surface.gradient, + ) + + assert res.success + assert np.allclose(res.x, minim, rtol=1e-4) + + +@requires_working_xtb_install +@work_in_tmp_dir() +def test_cubic_interpolation(): + mol = Molecule(smiles="CCO") + + en1, grad1 = _calculate_engrad_for_species(mol, XTB(), 1) + coords1 = CartesianCoordinates(mol.coordinates) + coords1.e = en1 + coords1.update_g_from_cart_g(grad1) + # take a steepest descent step + step = -coords1.g * 0.1 / np.linalg.norm(coords1.g) + coords2 = coords1 + step + mol.coordinates = coords2 + en2, grad2 = _calculate_engrad_for_species(mol, XTB(), 1) + coords2.e = en2 + coords2.update_g_from_cart_g(grad2) + # cubic interp + cubic_poly = Polynomial2PointFit.cubic_fit(coords1, coords2) + # energy at a point between 0 and 1 + coords3 = coords1 + 0.25 * step + mol.coordinates = coords3 + en3, _ = _calculate_engrad_for_species(mol, XTB(), 1) + interp_en = cubic_poly(0.25) + assert np.isclose(interp_en, en3, atol=1e-3) + + +def test_polynomial_max_min(): + # 1 - x + 2x^2 + x^3 + poly = Polynomial([1, -1, 2, 1]) + # known minimum = 0.215 + x_min = Polynomial2PointFit.get_extremum(poly, 0, 1) + assert np.isclose(x_min, 0.215, atol=1e-3) + # known maximum = -1.549 + x_max = Polynomial2PointFit.get_extremum(poly, -2, 0, get_max=True) + assert np.isclose(x_max, -1.549, atol=1e-3) + # order of bounds should not matter + x_max = Polynomial2PointFit.get_extremum(poly, 0, -2, get_max=True) + assert np.isclose(x_max, -1.549, atol=1e-3) + # should not count inflection points + poly = Polynomial([0, 0, 0, 1]) + x_min = Polynomial2PointFit.get_extremum(poly, -np.inf, np.inf) + x_max = Polynomial2PointFit.get_extremum( + poly, -np.inf, np.inf, get_max=True + ) + assert x_min is None and x_max is None diff --git a/autodE/source/tests/test_opt/test_prfo.py b/autodE/source/tests/test_opt/test_prfo.py new file mode 100644 index 0000000000000000000000000000000000000000..7a915a454f7e892c5a7440ce9e09c2098bff379f --- /dev/null +++ b/autodE/source/tests/test_opt/test_prfo.py @@ -0,0 +1,109 @@ +import numpy as np +from autode.species.molecule import Molecule +from autode.atoms import Atom +from autode.methods import XTB +from autode.opt.optimisers import PRFOptimiser +from autode.config import Config +from autode.utils import work_in_tmp_dir +from ..testutils import requires_working_xtb_install + +xtb = XTB() + + +def has_single_imag_freq_at_xtb_level(mol: Molecule) -> bool: + mol.calc_hessian(method=xtb) + assert mol.imaginary_frequencies is not None + return len(mol.imaginary_frequencies) == 1 + + +@requires_working_xtb_install +@work_in_tmp_dir() +def test_sn2_opt(): + mol = Molecule( + name="sn2_ts", + charge=-1, + solvent_name="water", + atoms=[ + Atom("F", -4.17085, 3.55524, 1.59944), + Atom("Cl", -0.75962, 3.53830, -0.72354), + Atom("C", -2.51988, 3.54681, 0.47836), + Atom("H", -3.15836, 3.99230, -0.27495), + Atom("H", -2.54985, 2.47411, 0.62732), + Atom("H", -2.10961, 4.17548, 1.25945), + ], + ) + + assert mol.is_implicitly_solvated + + PRFOptimiser.optimise(mol, method=xtb, maxiter=10, init_alpha=0.02) + + assert has_single_imag_freq_at_xtb_level(mol) + freq = mol.imaginary_frequencies[0] + assert np.isclose(freq.to("cm-1"), -555, atol=20) + + +@requires_working_xtb_install +@work_in_tmp_dir() +def test_diels_alder_ts_opt(): + xyz_file_string = ( + "16\n\n" + "C -0.00246842 1.65107949 0.05871997\n" + "C 1.19010335 1.11169078 0.27709357\n" + "C 1.58518503 -0.30014150 0.31048716\n" + "C 0.05830748 -1.54292177 -0.45110322\n" + "C -1.18801295 -1.07571606 0.14240225\n" + "C -1.28206230 0.99883443 -0.23631125\n" + "H -0.07432316 2.73634443 0.08639466\n" + "H 2.01755127 1.78921170 0.47735384\n" + "H 1.70502646 -0.70915918 1.30549866\n" + "H 2.40397525 -0.55376353 -0.34855409\n" + "H 0.44229481 -2.48695410 -0.08638411\n" + "H 0.15288739 -1.41865071 -1.51944246\n" + "H -1.25409868 -1.13318437 1.21833314\n" + "H -2.09996454 -1.35917816 -0.36714627\n" + "H -2.09461648 1.29054940 0.41494506\n" + "H -1.56001451 1.00182912 -1.28216692\n" + ) + with open("init.xyz", "w") as file: + print(xyz_file_string, file=file) + + mol = Molecule("init.xyz") + PRFOptimiser.optimise(mol, method=xtb, maxiter=25, init_alpha=0.05) + assert has_single_imag_freq_at_xtb_level(mol) + freq = mol.imaginary_frequencies[0] + assert np.isclose(freq, -600, atol=30) # should be ~600 cm-1 + + +@requires_working_xtb_install +@work_in_tmp_dir() +def test_mode_following(): + mol = Molecule( + name="sn2_ts", + charge=-1, + solvent_name="water", + atoms=[ + Atom("F", -4.17085, 3.55524, 1.59944), + Atom("Cl", -0.75962, 3.53830, -0.72354), + Atom("C", -2.51988, 3.54681, 0.47836), + Atom("H", -3.15836, 3.99230, -0.27495), + Atom("H", -2.54985, 2.47411, 0.62732), + Atom("H", -2.10961, 4.17548, 1.25945), + ], + ) + opt = PRFOptimiser(maxiter=10, conv_tol="normal", imag_mode_idx=0) + opt._species = mol + opt._method = xtb + opt._n_cores = Config.n_cores + opt._initialise_run() + # take a step + opt._step() + opt._update_gradient_and_energy() + opt._update_hessian() + # shift the Hessian modes by exchanging eigenvalues of first two modes + b, u = np.linalg.eigh(opt._coords.h) + b[0], b[1] = b[1], b[0] + new_h = np.linalg.multi_dot((u, np.diag(b), u.T)).real + b, u = np.linalg.eigh(new_h) + new_idx = opt._get_imag_mode_idx(u) + # the chosen index should be 1, based on overlap + assert new_idx == 1 diff --git a/autodE/source/tests/test_opt/test_qa.py b/autodE/source/tests/test_opt/test_qa.py new file mode 100644 index 0000000000000000000000000000000000000000..fa6770f2c16732a36a5ccadf7fd39ec5e255b1a0 --- /dev/null +++ b/autodE/source/tests/test_opt/test_qa.py @@ -0,0 +1,99 @@ +import os +import numpy as np +import pytest +from autode import Molecule, Atom, Config +from autode.methods import XTB +from autode.opt.optimisers.qa import QAOptimiser +from autode.opt.coordinates import DICWithConstraints +from autode.utils import work_in_tmp_dir +from ..testutils import work_in_zipped_dir, requires_working_xtb_install + + +here = os.path.dirname(os.path.abspath(__file__)) +datazip = os.path.join(here, "data", "opt.zip") + + +@work_in_zipped_dir(datazip) +def test_trm_step(): + mol = Molecule("opt-test.xyz") + opt = QAOptimiser(maxiter=10, conv_tol="loose", init_trust=0.1) + opt._species = mol + opt._build_internal_coordinates() + assert isinstance(opt._coords, DICWithConstraints) + + grad = np.loadtxt("opt-test_grad.txt") + hess = np.loadtxt("opt-test_hess.txt") + opt._coords.update_g_from_cart_g(grad) + opt._coords.update_h_from_cart_h(hess) + + opt._step() + step = np.array(opt._history.final) - np.array(opt._history.penultimate) + step_size = np.linalg.norm(step) + assert np.isclose(step_size, 0.1) + + +@work_in_tmp_dir() +@requires_working_xtb_install +def test_trust_update(): + init_trust = 0.05 + water_atoms = [ + Atom("O", -0.0011, 0.3631, -0.0000), + Atom("H", -0.8250, -0.1819, -0.0000), + Atom("H", 0.8261, -0.1812, 0.0000), + ] + water = Molecule(atoms=water_atoms) + opt = QAOptimiser(maxiter=10, conv_tol="loose", init_trust=init_trust) + + opt._species = water.copy() + opt._method = XTB() + opt._n_cores = Config.n_cores + opt._initialise_run() + # store last grad + last_g = opt._coords.g.copy() + last_h = opt._coords.h.copy() + + opt._step() + opt._update_gradient_and_energy() + last_step = np.array(opt._coords) - np.array(opt._history[-2]) + pred_delta_e = float(np.dot(last_g, last_step)) + pred_delta_e += 0.5 * np.linalg.multi_dot((last_step, last_h, last_step)) + # pred_dE should be around -0.002544605 Ha (depends on xTB version) + + def simulate_energy_change_ratio_update_trust(ratio): + opt.alpha = init_trust + opt._history.final.e = ( + opt._history.penultimate.e + ratio * pred_delta_e + ) + opt._update_trust_radius() + + # should not update if trust update turned off + opt._trust_update = False + simulate_energy_change_ratio_update_trust(0.2) + assert np.isclose(opt.alpha, init_trust) + + opt._trust_update = True + simulate_energy_change_ratio_update_trust(0.2) + assert np.isclose(opt.alpha, 0.7 * init_trust) + + simulate_energy_change_ratio_update_trust(0.5) + assert np.isclose(opt.alpha, init_trust) + + simulate_energy_change_ratio_update_trust(1.0) + assert (np.linalg.norm(last_step) - init_trust) / init_trust < 0.05 + assert np.isclose(opt.alpha, 1.3 * init_trust) + + simulate_energy_change_ratio_update_trust(1.3) + assert np.isclose(opt.alpha, init_trust) + + simulate_energy_change_ratio_update_trust(1.8) + assert np.isclose(opt.alpha, 0.7 * init_trust) + + +@work_in_tmp_dir() +@requires_working_xtb_install +def test_molecular_opt_qa(): + mol = Molecule(smiles="CCO") + constr_distance = mol.distance(1, 3) + 0.1 + mol.constraints.distance = {(1, 3): constr_distance} + QAOptimiser.optimise(mol, method=XTB(), maxiter=10) + assert np.isclose(mol.distance(1, 3), constr_distance, 1e-6) diff --git a/autodE/source/tests/test_opt/test_rfo.py b/autodE/source/tests/test_opt/test_rfo.py new file mode 100644 index 0000000000000000000000000000000000000000..ced3d1300361067b8aadf6b93bb3169243213aaf --- /dev/null +++ b/autodE/source/tests/test_opt/test_rfo.py @@ -0,0 +1,105 @@ +import numpy as np +from autode.species.molecule import Molecule +from autode.wrappers.XTB import XTB +from autode.utils import work_in_tmp_dir +from autode.opt.optimisers import ConvergenceParams +from autode.opt.optimisers.rfo import RFOptimiser +from autode.opt.coordinates import CartesianCoordinates +from ..testutils import requires_working_xtb_install +from .setup import Method + + +class TestRFOOptimiser2D(RFOptimiser): + """Simple 2D optimiser using a BFGS update step""" + + __test__ = False + + def __init__( + self, + e_func, + g_func, + init_x, + init_y, + maxiter=30, + conv_tol=ConvergenceParams(abs_d_e=1e-4, rms_g=1e-3), + **kwargs, + ): + super().__init__(maxiter=maxiter, conv_tol=conv_tol, **kwargs) + + init_arr = np.array([init_x, init_y]) + self._coords = CartesianCoordinates(init_arr) + self._coords.h = np.eye(2) + + self.e_func = e_func + self.g_func = g_func + + def _space_has_degrees_of_freedom(self) -> bool: + return True + + def _log_convergence(self) -> None: + x, y = self._coords + print(f"{x:.4f}, {y:.4f}", f"E = {round(self._coords.e, 5)}") + + def _update_gradient_and_energy(self) -> None: + x, y = self._coords + self._coords.e = self.e_func(x, y) + self._coords.g = self.g_func(x, y) + + def _initialise_run(self) -> None: + self._update_gradient_and_energy() + + +@work_in_tmp_dir() +def test_simple_quadratic_opt(): + optimiser = TestRFOOptimiser2D( + e_func=lambda x, y: x**2 + y**2, + g_func=lambda x, y: np.array([2.0 * x, 2.0 * y]), + init_y=1.0, + init_x=1.0, + init_alpha=0.5, + ) + optimiser.run(Molecule(name="blank"), method=Method()) + assert optimiser.converged + assert optimiser.iteration < 10 + + +@work_in_tmp_dir() +def test_branin_opt(): + def energy(x, y): + return (y - 0.129 * x**2 + 1.6 * x - 6) ** 2 + 6.07 * np.cos(x) + 10 + + def grad(x, y): + de_dx = 2 * (1.6 - 0.258 * x) * ( + y - 0.129 * x**2 + 1.6 * x - 6 + ) - 6.07 * np.sin(x) + + de_dy = 2 * (y - 0.129 * x**2 + 1.6 * x - 6) + + return np.array([de_dx, de_dy]) + + optimiser = TestRFOOptimiser2D( + e_func=energy, g_func=grad, init_y=14.0, init_x=6.0, init_alpha=2.0 + ) + optimiser.run(Molecule(name="blank"), method=Method()) + + assert optimiser.converged + assert np.allclose(optimiser._coords, np.array([3.138, 2.252]), atol=0.02) + + assert optimiser.iteration < 30 + + +@work_in_tmp_dir(filenames_to_copy=[], kept_file_exts=[]) +@requires_working_xtb_install +def test_molecular_opt(): + mol = Molecule(smiles="O") + assert [atom.label for atom in mol.atoms] == ["O", "H", "H"] + + RFOptimiser.optimise(mol, method=XTB()) + + # Check optimised distances are similar to running the optimiser in XTB + for oh_atom_idx_pair in [(0, 1), (0, 2)]: + assert np.isclose( + mol.distance(*oh_atom_idx_pair).to("Å"), 0.9595, atol=1e-2 + ) + + assert np.isclose(mol.distance(1, 2), 1.5438, atol=1e-2) diff --git a/autodE/source/tests/test_path.py b/autodE/source/tests/test_path.py new file mode 100644 index 0000000000000000000000000000000000000000..5310b409f2c0a47da8889b3aec65c865bfc21a7c --- /dev/null +++ b/autodE/source/tests/test_path.py @@ -0,0 +1,286 @@ +import os +import numpy as np +import pytest + +from autode.atoms import Atom +from autode.methods import XTB +from autode.path import Path, AdaptivePath +from autode.path.adaptive import pruned_active_bonds +from autode.path.interpolation import CubicPathSpline +from autode.input_output import xyz_file_to_molecules +from autode.bonds import FormingBond, BreakingBond +from autode.species import Species, Molecule +from autode.units import Unit, KcalMol +from autode.geom import calc_rmsd +from . import testutils + +here = os.path.dirname(os.path.abspath(__file__)) +spline_datazip = os.path.join(here, "data", "spline_fit.zip") + +test_species = Species(name="tmp", charge=0, mult=1, atoms=[Atom("He")]) +test_mol = Molecule(smiles="O") + + +def test_path_properties_empty(): + path = Path() + + assert len(path) == 0 + assert isinstance(path.units, Unit) + + assert path == Path() # should be able to compare paths + assert path != 0 + + with pytest.raises(Exception): + _ = Path("does not have correct attributes") + + # With no species there should be no peak/saddle/energies + assert len(path.rel_energies) == 0 + assert len(path.energies) == 0 + + assert not path.contains_peak + assert path.peak_idx is None + assert not path.is_saddle(idx=0) + + # Should not plot plot a path without any structures + path.plot_energies(save=True, name="tmp", color="black", xlabel="none") + assert not os.path.exists("tmp.pdf") + + +def test_path_properties(): + p1 = test_species.copy() + p1.energy = -3 + p2 = test_species.copy() + p2.energy = -2 + + path = Path(p1, p2, units=KcalMol) + assert all(np.isclose(path.energies, np.array([-3, -2]))) + assert all(np.isclose(path.rel_energies, 627.509 * np.array([0, 1]))) + + p3 = test_species.copy() + path = Path(p1, p2, p3) + + # There is an energy not set, should not be able to find a peak + assert path.peak_idx is None + assert not path.contains_peak + assert not path.is_saddle(idx=1) + + # setting the energy of the final point should allow a peak + path[2].energy = -3 + assert path.contains_peak + assert path.peak_idx == 1 + assert path.is_saddle(idx=1) + + path.plot_energies(save=True, name="tmp", color="black", xlabel="none") + assert os.path.exists("tmp.pdf") + os.remove("tmp.pdf") + + # Should ba able to print an xyz file containing the structures along the + # path + path.print_geometries(name="tmp") + assert os.path.exists("tmp.xyz") + os.remove("tmp.xyz") + + +def test_point_properties(): + point = test_species.copy() + + assert point.energy is None + assert point.gradient is None + assert not point.constraints.any + assert point.name == "tmp" + + +def test_pruning_bonds(): + h3 = Species( + name="h3", + charge=0, + mult=2, + atoms=[Atom("H"), Atom("H", x=1), Atom("H", x=0.5, y=0.5)], + ) + + fbond = FormingBond(atom_indexes=(0, 1), species=h3) + bbond1 = BreakingBond(atom_indexes=(0, 2), species=h3) + bbond2 = BreakingBond(atom_indexes=(1, 2), species=h3) + + new_bonds = pruned_active_bonds( + reactant=h3, fbonds=[fbond], bbonds=[bbond1, bbond2] + ) + assert len(new_bonds) == 2 + # Should prune to one breaking and one forming bond + assert ( + isinstance(new_bonds[0], FormingBond) + and isinstance(new_bonds[1], BreakingBond) + ) or ( + isinstance(new_bonds[1], FormingBond) + and isinstance(new_bonds[0], BreakingBond) + ) + + # Test the correct assigment of the final bond distance + ru_reac = Species( + name="Ru_alkene", + charge=0, + mult=1, + atoms=[ + Atom("Ru", 0.45366, 0.70660, -0.25056), + Atom("C", 0.72920, 1.42637, 1.37873), + Atom("C", -1.75749, -0.39358, 0.57059), + Atom("C", -1.10229, -1.02739, -0.43978), + ], + ) + + ru_prod = Species( + name="Ru_cycylobutane", + charge=0, + mult=1, + atoms=[ + Atom("Ru", 0.28841, -1.68905, 0.39833), + Atom("C", -0.85865, -0.07597, -0.29711), + Atom("C", 0.10995, 0.44156, -1.35018), + Atom("C", 1.26946, -0.42574, -0.91200), + ], + ) + + bbond = BreakingBond( + atom_indexes=[0, 2], species=ru_reac, final_species=ru_prod + ) + + assert np.isclose(bbond.final_dist, ru_prod.distance(0, 2)) + + +def test_pruning_bonds2(): + h2 = Species( + name="h2", charge=0, mult=2, atoms=[Atom("H"), Atom("H", x=1)] + ) + + h2_close = Species( + name="h2", charge=0, mult=2, atoms=[Atom("H"), Atom("H", x=0.5)] + ) + + bbond = BreakingBond( + atom_indexes=[0, 1], species=h2, final_species=h2_close + ) + + # A breaking bond with a final distance shorter than the current + # (which is possible) should be pruned + assert len(pruned_active_bonds(h2, fbonds=[], bbonds=[bbond])) == 0 + + +def test_products_made(): + path = Path(test_mol) + + assert not path.products_made(product=None) + # Species have no graphs + assert not path.products_made(product=test_species) + + # with a single point and a molecule with the same graph then the products + # are made, at the first point + assert path.products_made(product=test_mol) + + diff_mol = test_mol.copy() + diff_mol.graph.remove_edge(0, 1) + assert not path.products_made(product=diff_mol) + + +@testutils.requires_working_xtb_install +def test_adaptive_path(): + species_no_atoms = Species(name="tmp", charge=0, mult=1, atoms=[]) + + with pytest.raises(Exception): + # cannot create a path with a molecule with no atoms + _ = AdaptivePath(init_species=species_no_atoms, bonds=[], method=XTB()) + + path1 = AdaptivePath( + init_species=Molecule(smiles="O"), bonds=[], method=XTB() + ) + + assert len(path1) == 1 + assert path1.method.name == "xtb" + assert len(path1.bonds) == 0 + + assert path1 != 0 + assert path1 == path1 + + +@testutils.work_in_zipped_dir(spline_datazip) +def test_path_spline_fitting(): + species_list = xyz_file_to_molecules("da_neb_optimised_20.xyz") + species_list[ + 0 + ].energies.clear() # delete one energy to prevent energy fitting + spline = CubicPathSpline.from_species_list(species_list) + + # point locations should be normalised + assert min(spline.path_distances) == 0 + assert max(spline.path_distances) == 1 + + # energy related methods should raise exception as energy not fitted + with pytest.raises(RuntimeError, match="Energy spline must be fitted"): + spline.energy_peak() + + with pytest.raises(RuntimeError, match="Must have fitted energies"): + spline.energy_at(0.5) + + +@testutils.work_in_zipped_dir(spline_datazip) +def test_path_spline_energy_peak(): + da_20_path = xyz_file_to_molecules("da_neb_optimised_20.xyz") + da_30_path = xyz_file_to_molecules("da_neb_optimised_30.xyz") + + peak_idx = np.argmax([mol.energy for mol in da_30_path]) + da_30_peak_coords = da_30_path[int(peak_idx)].coordinates + + path_20_spline = CubicPathSpline.from_species_list(da_20_path) + peak_x = path_20_spline.energy_peak() + peak_coords = path_20_spline.coords_at(peak_x).reshape(-1, 3) + + # check that the predicted peak is close to actual peak + assert calc_rmsd(da_30_peak_coords, peak_coords) < 0.02 + + +@testutils.work_in_zipped_dir(spline_datazip) +def test_path_spline_integral(): + da_20_path = xyz_file_to_molecules("da_neb_optimised_20.xyz") + da_30_path = xyz_file_to_molecules("da_neb_optimised_30.xyz") + spline_20 = CubicPathSpline.from_species_list(da_20_path) + spline_30 = CubicPathSpline.from_species_list(da_30_path) + + length_20 = spline_20.path_integral() + length_30 = spline_30.path_integral() + + # length should approximately be the same + assert np.isclose(length_20, length_30, atol=0.1) + + +@testutils.work_in_zipped_dir(spline_datazip) +def test_path_spline_ivp(): + # initial value problem, integrate upto a certain length + species_list = xyz_file_to_molecules("da_neb_optimised_20.xyz") + spline = CubicPathSpline.from_species_list(species_list) + + length_tot = spline.path_integral(0, 1) + # choose ten random lengths and check if the integration + # and ivp gives same result + fractions = list(np.random.uniform(0, 1, size=10)) + for frac in fractions: + length = length_tot * frac + ivp_x = spline.integrate_upto_length(length) + assert np.isclose(spline.path_integral(0, ivp_x), length, atol=1e-4) + + +@testutils.requires_working_xtb_install +@testutils.work_in_zipped_dir(spline_datazip) +def test_path_spline_energy_predictions(): + # NEB path optimised at xTB level + species_list = xyz_file_to_molecules("da_neb_optimised_30.xyz") + spline = CubicPathSpline.from_species_list(species_list) + + test_points = list(np.random.uniform(0, 1, size=10)) + for idx, point in enumerate(test_points): + pred_e = spline.energy_at(point) + pred_coords = spline.coords_at(point) + tmp_spc = species_list[0].new_species(name=f"calc_{idx}") + tmp_spc.coordinates = pred_coords + tmp_spc.single_point(method=XTB()) + actual_e = float(tmp_spc.energy.to("Ha")) + # does not seem to be very accurate! + assert np.isclose(pred_e, actual_e, atol=0.02) diff --git a/autodE/source/tests/test_pes/__init__.py b/autodE/source/tests/test_pes/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/autodE/source/tests/test_pes/data.zip b/autodE/source/tests/test_pes/data.zip new file mode 100644 index 0000000000000000000000000000000000000000..12b86e00916f4177f349a2abd793c6059c56040e --- /dev/null +++ b/autodE/source/tests/test_pes/data.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5c3768cda2ed0c055fd2c86488b3884547c2a6bec0500fff9764e3a26edad92b +size 122724 diff --git a/autodE/source/tests/test_pes/sample_pes.py b/autodE/source/tests/test_pes/sample_pes.py new file mode 100644 index 0000000000000000000000000000000000000000..5539a36c65ce2443cfe04f68760710520949852b --- /dev/null +++ b/autodE/source/tests/test_pes/sample_pes.py @@ -0,0 +1,37 @@ +import numpy as np +from autode.values import EnergyArray as Energies +from autode.pes.reactive import ReactivePESnD + + +class TestPES(ReactivePESnD): + __test__ = False + + def __init__(self, rs, species=None): + super().__init__(species=species, rs=rs) + + def _default_keywords(self, method): + raise NotImplementedError + + @property + def _default_keyword_type(self): + raise NotImplementedError + + def _calculate(self) -> None: + """Skip the calculation in the test class""" + + +def harmonic_2d_pes(): + # Symmetric PES in x and y (atom indexes are dummy) + pes = TestPES( + rs={ + (0, 1): np.linspace(-1, 1, num=21), + (1, 2): np.linspace(-1, 1, num=21), + } + ) + + def energy(x, y): + return 0.01 * (x**2 + y**2) + + pes._energies = Energies(energy(pes.r1, pes.r2)) + + return pes diff --git a/autodE/source/tests/test_pes/test_base_class.py b/autodE/source/tests/test_pes/test_base_class.py new file mode 100644 index 0000000000000000000000000000000000000000..3c86350b1a655219ad41dfe56fa88b6b10dfd5a7 --- /dev/null +++ b/autodE/source/tests/test_pes/test_base_class.py @@ -0,0 +1,114 @@ +import os +import numpy as np +import pytest +from autode.atoms import Atom +from autode.methods import ORCA +from autode.species.molecule import Molecule +from autode.pes.pes_nd import PESnD +from .sample_pes import TestPES, harmonic_2d_pes + +here = os.path.dirname(os.path.abspath(__file__)) + + +def h2(): + return Molecule(atoms=[Atom("H"), Atom("H", x=0.70)]) + + +def test_calculate(): + pes = TestPES(species=h2(), rs={(0, 1): (1.5, 10)}) + + orca = ORCA() + + # Cannot calculate the energy with a _calculate method that does nothing + with pytest.raises(RuntimeError): + pes.calculate(method=orca) + + # and the number of TS guesses should be 0 + assert len(list(pes.ts_guesses())) == 0 + + +def test_plot_and_save_3d(): + pes = TestPES( + rs={ + (0, 1): np.linspace(1, 2, num=10), + (1, 2): np.linspace(1, 2, num=10), + (2, 3): np.linspace(1, 2, num=10), + } + ) + pes._energies.fill(0.0) + + # Cannot plot a PES that has 3 spatial dimensions and one energy in 3D + with pytest.raises(Exception): + pes.plot() + + # but can save the energies as a .txt file, which will flatten it + pes.save(filename="tmp.txt") + assert np.loadtxt("tmp.txt").shape == (1000,) + + os.remove("tmp.txt") + + +def test_clear(): + pes = TestPES(species=h2(), rs={(0, 1): (1.5, 10)}) + pes._coordinates = np.ones( + shape=(10, 2, 3) # 10 points # 2 atoms + ) # x, y, z dimensions + + pes.clear() + + # Clear should reset the energies and zero the coordinates + for point in pes._points(): + assert np.all(np.isnan(pes._energies[point])) + assert np.allclose(pes._coordinates[point], 0.0) + + +def test_point_neighbour(): + pes = TestPES(rs={(0, 1): np.array([1.0, 2.0, 3.0])}) + + # A point is not a neighbour if ∆p is zero + with pytest.raises(ValueError): + pes._neighbour(point=(1,), dim=0, delta=0) + + # but is if ∆p = +1 for instance + p = pes._neighbour(point=(1,), dim=0, delta=1) + assert p == (2,) + + # a point (3,) is not on the surface, as the only valid indices are 0, 1, 2 + p = pes._neighbour(point=(1,), dim=0, delta=2) + assert not pes._is_contained(p) + + +def test_spline(): + """Ensure that no matter the ordering of energies a spline can be fit + to a 2D surface""" + + pes = harmonic_2d_pes() + + _ = pes._spline_2d() + + for i in (0, 1): + for direction in (1, -1): + pes._rs[i] = pes._rs[i][::-direction] + pes._mesh() + _ = pes._spline_2d() # Should still be able to spline + + +def test_relative_energies(): + rel_energies = harmonic_2d_pes().relative_energies + + # Minimum should be 0 + assert np.isclose(np.min(rel_energies), 0.0, atol=1e-6) + + # and support conversion between units + assert hasattr(rel_energies, "to") + + +def test_reload_from_only_file(): + harmonic_2d_pes().save("tmp.npz") + assert os.path.exists("tmp.npz") + + loaded_pes = TestPES.from_file("tmp.npz") + assert isinstance(loaded_pes, PESnD) + assert loaded_pes.shape == (21, 21) + + os.remove("tmp.npz") diff --git a/autodE/source/tests/test_pes/test_calculate.py b/autodE/source/tests/test_pes/test_calculate.py new file mode 100644 index 0000000000000000000000000000000000000000..aaf00858420a9fc2848afe697de389e898cca23a --- /dev/null +++ b/autodE/source/tests/test_pes/test_calculate.py @@ -0,0 +1,57 @@ +import numpy as np +import pytest +from .. import testutils +from autode.utils import work_in_tmp_dir +from autode.species import Molecule +from autode.atoms import Atom +from autode.methods import XTB +from autode.pes.relaxed import RelaxedPESnD + + +def h2(): + return Molecule(atoms=[Atom("H"), Atom("H", x=0.70)]) + + +@work_in_tmp_dir(filenames_to_copy=[], kept_file_exts=[]) +def test_calculate_no_species(): + pes = RelaxedPESnD(species=None, rs={}) + + # cannot calculate a PES without a species + with pytest.raises(ValueError): + pes.calculate(method=XTB()) + + +@testutils.requires_working_xtb_install +@work_in_tmp_dir(filenames_to_copy=[], kept_file_exts=[]) +def test_calculate_1d(): + pes = RelaxedPESnD(species=h2(), rs={(0, 1): (1.5, 10)}) + + pes.calculate(method=XTB()) + + # All points should have a defined energy + for i in range(10): + assert pes._has_energy(point=(i,)) + + +@testutils.requires_working_xtb_install +@work_in_tmp_dir(filenames_to_copy=[], kept_file_exts=[]) +def test_calculate_1d_serial(): + """ + Ensure that a serial calculation results in the same + PES as the default parallel analogue, although on a 1D + surface they should be the same + """ + + n_points = 5 + pes = RelaxedPESnD(species=h2(), rs={(0, 1): (1.0, n_points)}) + + pes.calculate(method=XTB()) + energies = [pes[i] for i in range(n_points)] + pes.clear() + + # Recalculate in serial + for i in range(n_points): + e, coords = pes._single_energy_coordinates(pes._species_at(point=(i,))) + pes._energies[i] = e + pes._coordinates[i] = coords + assert np.isclose(energies[i], e, atol=1e-6) diff --git a/autodE/source/tests/test_pes/test_load_save.py b/autodE/source/tests/test_pes/test_load_save.py new file mode 100644 index 0000000000000000000000000000000000000000..d17d32b6869a4cb0e265137d16a7aa45d65a5c9c --- /dev/null +++ b/autodE/source/tests/test_pes/test_load_save.py @@ -0,0 +1,79 @@ +import os +import numpy as np +import pytest +from .. import testutils +from .sample_pes import TestPES +from autode.utils import work_in_tmp_dir +from autode.pes.pes_nd import EnergyArray as Energies + +here = os.path.dirname(os.path.abspath(__file__)) + + +def test_save_empty(): + pes = TestPES(rs={}) + + with pytest.raises(ValueError): + pes.save("tmp") + + +@work_in_tmp_dir(filenames_to_copy=[], kept_file_exts=[]) +def test_save_1d(): + pes = TestPES(rs={(0, 1): (0.1, 0.2, 3)}) + assert pes.shape == (3,) + + pes.save("tmp") + # .npz should be automatically added + assert os.path.exists("tmp.npz") + + with pytest.raises(Exception): + pes.load("a_file_that_does_not_exist") + + pes.load("tmp.npz") + assert pes.shape == (3,) + + # Should also be able to save the pure .txt file of energies + pes.save("tmp.txt") + assert os.path.exists("tmp.txt") + + loaded_arr = np.loadtxt("tmp.txt") + assert loaded_arr.shape == (3,) + + # Cannot reload from a .txt file + with pytest.raises(ValueError): + pes.load("tmp.txt") + + +def save_3d_as_text_file(): + pes = TestPES( + rs={ + (0, 1): (0.1, 0.2, 3), + (1, 2): (0.1, 0.2, 3), + (2, 3): (0.1, 0.2, 3), + } + ) + pes._energies = Energies(np.ones(shape=(3, 3))) + pes.save(filename="tmp.txt") + + # 3D, or more PESs should be flattened to be saved as a .txt file + assert os.path.exists("tmp.txt") + assert np.loadtxt("tmp.txt").shape == (9,) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data.zip")) +def test_save_plot(): + """Not easy to test what these look like, so just check that + the plots exist...""" + + pes = TestPES(rs={}) + + for filename in ("pes1d_water.npz", "pes2d_water.npz"): + pes.load(filename) + + with pytest.raises(Exception): + # Cannot plot with a negative interpolation factor + pes.plot("tmp.pdf", interp_factor=-1) + + for interp_factor in (0, 2): + pes.plot("tmp.pdf", interp_factor=interp_factor) + assert os.path.exists("tmp.pdf") + os.remove("tmp.pdf") diff --git a/autodE/source/tests/test_pes/test_mep.py b/autodE/source/tests/test_pes/test_mep.py new file mode 100644 index 0000000000000000000000000000000000000000..25fe4212560466ed00e58c2c350fc9612f438d89 --- /dev/null +++ b/autodE/source/tests/test_pes/test_mep.py @@ -0,0 +1,104 @@ +import os +import numpy as np +from .. import testutils +from .sample_pes import TestPES +from autode.pes.mep import peak_point +from autode.pes.relaxed import RelaxedPESnD +from autode import Molecule, Atom + +here = os.path.dirname(os.path.abspath(__file__)) + + +def flat_h2_pes(): + """Flat H2 PES with shape (11,)""" + + pes = TestPES( + rs={(0, 1): np.linspace(-1, 1, num=11)}, + species=Molecule(atoms=[Atom("H"), Atom("H", x=0.7)]), + ) + pes._energies.fill(-1.0) + pes._coordinates = np.zeros(shape=(11, 2, 3)) + pes._coordinates[:, 1, 0] = 0.7 # Set all x values for H_b to 0.7 Å + + return pes + + +def test_simple_peak(): + pes = TestPES( + rs={ + (0, 1): np.linspace(-1, 1, num=11), + (1, 2): np.linspace(-1, 1, num=11), + } + ) + + p = peak_point( + energies=0.01 * (pes.r1**2 - pes.r2**2), + point1=(5, 0), + point2=(5, 10), + ) + + assert p == (5, 5) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data.zip")) +def test_sn2_ts_guesses(): + r = Molecule( + name="reac", + charge=-1, + mult=1, + atoms=[ + Atom("F", -6.16710, 4.34010, 0.14884), + Atom("Cl", -0.96539, 4.54693, -0.06342), + Atom("C", -2.73988, 4.47792, 0.00591), + Atom("H", -3.09044, 3.88869, -0.83567), + Atom("H", -3.02574, 4.01317, 0.94432), + Atom("H", -3.12217, 5.49228, -0.05113), + ], + ) + + pes = RelaxedPESnD(species=r, rs={(0, 2): (1.45, 10), (2, 1): (2.5, 10)}) + pes.load("sn2_pes.npz") + + prod = Molecule( + name="prod", + charge=-1, + mult=1, + atoms=[ + Atom("F", -4.94585, 4.39058, 0.09591), + Atom("Cl", -1.00002, 4.54623, -0.06354), + Atom("C", -3.50010, 4.44743, 0.03750), + Atom("H", -3.23304, 3.87507, -0.83916), + Atom("H", -3.16714, 4.00107, 0.96305), + Atom("H", -3.26455, 5.49873, -0.04490), + ], + ) + + # Specifying the product should use a different algorithm, but return the + # same TS guess geometry + ts_guesses = list(pes.ts_guesses(product=prod)) + assert len(ts_guesses) == 1 + ts_guess = ts_guesses[0] + + assert np.isclose(ts_guess.distance(0, 2).to("Å"), 1.891, atol=0.1) + assert np.isclose(ts_guess.distance(2, 1).to("Å"), 2.179, atol=0.1) + + +def test_mep_ts_guess_no_graph(): + pes = flat_h2_pes() + + h2_no_graph = Molecule(atoms=[Atom("H"), Atom("H", x=1.0)]) + h2_no_graph.graph = None + + # Cannot find TS guesses from a product if it doesn't have a graph + assert len(list(pes.ts_guesses(product=h2_no_graph))) == 0 + + +def test_mep_ts_guess_no_isomorphism(): + pes = flat_h2_pes() + + h2_no_bond = Molecule(atoms=[Atom("H"), Atom("H", x=2.0)]) + assert len(h2_no_bond.graph.edges) == 0 + + # Cannot find TS guess if the product is not isomorphic to any point on the + # surface + assert len(list(pes.ts_guesses(product=h2_no_bond))) == 0 diff --git a/autodE/source/tests/test_pes/test_points.py b/autodE/source/tests/test_pes/test_points.py new file mode 100644 index 0000000000000000000000000000000000000000..6af9e221e49847adc56cd4ba88e8c6b4cdf8b925 --- /dev/null +++ b/autodE/source/tests/test_pes/test_points.py @@ -0,0 +1,228 @@ +import pytest +import numpy as np +from autode.pes.pes_nd import EnergyArray as Energies +from autode.pes.relaxed import RelaxedPESnD as PESnD +from .sample_pes import TestPES, harmonic_2d_pes + + +class RelaxedPESnD(PESnD): + __test__ = False + + def __init__(self, species=None, rs=None): + super(RelaxedPESnD, self).__init__(species=species, rs=rs) + + +def test_point_list_1d(): + pes = TestPES(rs={(0, 1): (1.0, 2.0, 3)}) + assert pes.ndim == 1 + assert list(pes._points()) == [(0,), (1,), (2,)] + + +def test_point_list_2d(): + pes = TestPES(rs={(0, 1): (1.0, 2.0, 2), (1, 2): (1.0, 2.0, 2)}) + assert pes.ndim == 2 + assert pes.shape == (2, 2) + + assert list(pes._points()) == [(0, 0), (0, 1), (1, 0), (1, 1)] + + +def test_point_list_non_square(): + pes = TestPES(rs={(0, 1): (1.0, 2.0, 2), (1, 2): (1.0, 3.0, 3)}) + + assert pes.ndim == 2 and pes.shape == (2, 3) + + points = pes._points() + assert points == [ + (0, 0), + (0, 1), + (1, 0), + (0, 2), + (1, 1), + (1, 2), + ] or points == [ + (0, 0), + (0, 1), + (1, 0), + (1, 1), + (0, 2), + (1, 2), + ] + + assert np.isclose(pes.r1[1, 2], 2.0, atol=1e-10) + assert np.isclose(pes.r2[1, 2], 3.0, atol=1e-10) + + +def test_closest_coordinates(): + pes = RelaxedPESnD(rs={(0, 1): (1.0, 2.0, 3)}) + + # Set a zero set of coordinates for 3 points + # one atom with x, y, z coordinates + pes._coordinates = np.zeros(shape=(3, 1, 3)) + + # and the origin set of coordinates (1.0, 1.0, 1.0) + pes._coordinates[0] = np.ones(shape=(1, 3)) + # with a defined energy + pes._energies[0] = -1.0 + + # So the closet point to the second (index (1,)) + # with an energy is the origin point + assert np.allclose( + pes._closest_coordinates(point=(1,)), np.ones(shape=(1, 3)), atol=1e-10 + ) + + +def test_distance(): + pes = TestPES(rs={(0, 1): np.array([1.0, 2.0])}) + + assert np.isclose(pes._distance(point1=(0,), point2=(1,)), 1.0, atol=1e-10) + + pes = TestPES( + rs={(0, 1): np.array([1.0, 2.0]), (1, 2): np.array([1.0, 2.0])} + ) + + assert np.isclose( + pes._distance(point1=(0, 0), point2=(1, 1)), np.sqrt(2), atol=1e-10 + ) + + +def test_closest_coordinates_no_energy(): + pes = RelaxedPESnD(rs={(0, 1): (1.0, 2.0, 3)}) + + pes._coordinates = np.zeros(shape=(3, 1, 3)) + + # Raises a runtime error if there is no suitable point + with pytest.raises(RuntimeError): + pes._closest_coordinates(point=(1,)) + + +def test_constraints_1d(): + pes = RelaxedPESnD(rs={(0, 1): (0.1, 0.3, 3)}) + + consts = pes._constraints(point=(0,)) + assert len(consts) == 1 + assert np.isclose(consts[(0, 1)], 0.1, atol=1e-10) + + for i in range(3): + consts = pes._constraints(point=(i,)) + assert np.isclose(consts[(0, 1)], 0.1 * (i + 1), atol=1e-10) + + +def test_invalid_constraints_1d(): + pes = RelaxedPESnD(rs={(0, 1): (0.1, 0.3, 3)}) + + # Cannot determine constraints for a point not on the surface + with pytest.raises(ValueError): + pes._constraints(point=(-1,)) + + with pytest.raises(ValueError): + pes._constraints(point=(0, 0)) + + with pytest.raises(ValueError): + pes._constraints(point=(3,)) + + +def test_stationary_points_1d(): + """For a set 1D PESs ensure the stationary points can be found""" + + pes = TestPES(rs={(0, 1): (1.0, 2.0, 3)}) + + pes._energies = Energies(np.array([1.0, 0.01, 1.0])) + assert len(list(pes._stationary_points())) == 1 + + pes._energies = Energies(np.array([1.0, 1.1, 1.2])) + assert len(list(pes._stationary_points())) == 0 + + pes._energies = Energies(np.array([-1.0, -1.1, -1.2])) + assert len(list(pes._stationary_points())) == 0 + + # Idential energies will return the middle point + pes._energies = Energies(np.array([-1.0, -1.0, -1.0])) + stat_points = list(pes._stationary_points()) + assert len(stat_points) == 1 + assert stat_points[0] == (1,) + + +def test_stationary_points_2d(): + def energy(x, y): + return 0.01 * (x * y - x**2 - x * y**2) + + pes = TestPES(rs={(0, 1): (-1.5, 1.5, 11), (1, 0): (-1.5, 1.5, 11)}) + + pes._energies = Energies(energy(pes.r1, pes.r2)) + # pes.plot('tmp.pdf', interp_factor=0) + # assert pes.shape == (50, 50) + + # Should have at least one stationary point. While in the + # continuous surface there is 3, the finite surface may not have + stat_points = list(pes._stationary_points()) + assert len(stat_points) > 0 + # The central point close to (0, 0) really should be present + assert len([p for p in stat_points if p == (5, 5)]) == 1 + + +def test_saddle_points_2d(): + def energy(x, y): + return -(x**2) + y**2 + + pes = TestPES(rs={(0, 1): (-1.0, 1.0, 11), (1, 0): (-1.0, 1.0, 11)}) + + pes._energies = Energies(energy(pes.r1, pes.r2)) + # pes.plot('tmp.pdf', interp_factor=0) + + assert len(list(pes._stationary_points())) == 1 + + # Should have at least one stationary point. While in the + # continuous surface there is 3, the finite surface may not have + points = list(pes._saddle_points()) + assert len(points) == 1 + + p = points[0] + + # Saddle point should be close to (0, 0) + assert np.isclose(pes.r1[p], 0.0, atol=0.1) + assert np.isclose(pes.r2[p], 0.0, atol=0.1) + + +def test_numerical_gradient_harmonic_well(): + pes = harmonic_2d_pes() + + # Gradients should be initialised to nan + assert all(np.isnan(g_k) for g_k in pes._gradients[1, 1]) + + # With set gradients they should be a minimum at the centre + # i.e close to r1=0, r2=0 + pes._set_gradients() + + # Norm is taken over the final axis (with length 2) + norm_grad = np.linalg.norm(pes._gradients, axis=2) + assert np.unravel_index(np.argmin(norm_grad), norm_grad.shape) == (10, 10) + + +def test_gradient_some_undefined_energies(): + pes = harmonic_2d_pes() + i, j = pes.shape + + pes._energies[i // 3, j // 3] = np.nan + + # Should not raise any kind of exception, even though one of the + # energies is undefined + pes._set_gradients() + + # Should still have a stationary point, even if a point is undefined, + # so long as it's not the stationary one + assert len(list(pes._stationary_points())) > 0 + + +def test_grad_neither_side_has_energy(): + pes = TestPES(rs={(0, 1): np.array([1.0, 2.0, 3.0])}) + + mid_point = (1,) + pes._energies[mid_point] = 1.0 + pes._set_gradients() + + # Cannot determine the numerical gradient if both enegies either side of + # the mid-point do not have an energy + assert np.all(np.isnan(pes._gradients[mid_point])) + + # Thus cannot be a minimum in |g| + assert not pes._is_minimum_in_gradient(mid_point) diff --git a/autodE/source/tests/test_pes/test_relaxed.py b/autodE/source/tests/test_pes/test_relaxed.py new file mode 100644 index 0000000000000000000000000000000000000000..1e8a9077b54eb9dd07494e947055265817c4b29b --- /dev/null +++ b/autodE/source/tests/test_pes/test_relaxed.py @@ -0,0 +1,214 @@ +import os +import numpy as np +import pytest +from .. import testutils +from autode.atoms import Atom +from autode.species import Molecule +from autode.wrappers.ORCA import ORCA +from autode.wrappers.keywords import OptKeywords +from autode.pes.relaxed import RelaxedPESnD as PESnD +from autode.units import Unit, energy_unit_from_name +from autode.utils import work_in_tmp_dir + +here = os.path.dirname(os.path.abspath(__file__)) + + +class RelaxedPESnD(PESnD): + def __init__(self, species=None, rs=None): + super(RelaxedPESnD, self).__init__(species=species, rs=rs) + + +def test_points_gen_idxs_1d(): + pes1d = RelaxedPESnD(rs={(0, 1): (1.0, 2.0, 3)}) + + expected_points = [[(0,)], [(1,)], [(2,)]] + for expected, true in zip(expected_points, pes1d._points_generator()): + assert expected == true + + +def test_points_gen_idxs_2d(): + pes2d = RelaxedPESnD(rs={(0, 1): (1.0, 2.0, 2), (1, 2): (1.0, 2.0, 2)}) + + assert len(list(pes2d._points())) == 4 + + # For a 2D grid there is points with indices that sum to 0, 1 and 2 + expected_points = [[(0, 0)], [(0, 1), (1, 0)], [(1, 1)]] + + for expected, true in zip(expected_points, pes2d._points_generator()): + # order doesn't matter, so convert to sets + assert set(expected) == set(true) + + +def test_points_gen_idxs_3d(): + pes3d = RelaxedPESnD( + rs={ + (0, 1): (1.0, 2.0, 2), + (1, 2): (1.0, 2.0, 2), + (2, 3): (1.0, 2.0, 2), + } + ) + + assert pes3d.shape == (2, 2, 2) + + expected_points = [ + [(0, 0, 0)], + [(0, 0, 1), (0, 1, 0), (1, 0, 0)], + [(0, 1, 1), (1, 0, 1), (1, 1, 0)], + [(1, 1, 1)], + ] + + for expected, true in zip(expected_points, pes3d._points_generator()): + # order doesn't matter, so convert to sets + assert set(expected) == set(true) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data.zip")) +def test_relaxed_with_keywords(): + pes = RelaxedPESnD( + Molecule(atoms=[Atom("H"), Atom("H", x=0.70)]), rs={(0, 1): (1.5, 5)} + ) + + orca = ORCA() + + # Spoof ORCA availability + orca.path = here + assert orca.is_available + + pes.calculate( + method=orca, keywords=OptKeywords(["PBE", "def2-SVP", "LooseOpt"]) + ) + + # Ensure the PES has been populated, using the saved output files + assert all(e < -1 for e in pes._energies) + + # Ensure the correct keywords have been used *NOTE* needs whitespace + assert os.path.exists("H2_scan_0_orca.inp") + assert "pbe " in open("H2_scan_0_orca.inp", "r").readline().lower() + + +@work_in_tmp_dir(filenames_to_copy=[], kept_file_exts=[]) +def test_calculate_single_without_est(): + pes = RelaxedPESnD( + Molecule(atoms=[Atom("H"), Atom("H", x=0.70)]), rs={(0, 1): (1.5, 5)} + ) + + orca = ORCA() + orca.path = "/a/path/that/does/not/exist" + assert not orca.is_available + + # Cannot calculate a surface without a working method + with pytest.raises(RuntimeError): + pes.calculate(method=orca) + + +def test_units_name_to_units(): + unit = energy_unit_from_name("eV") + assert isinstance(unit, Unit) + assert unit.name.lower() == "ev" + + with pytest.raises(Exception): + _ = energy_unit_from_name("ang") + + +@testutils.work_in_zipped_dir(os.path.join(here, "data.zip")) +def test_sn2_ts_guesses(): + reac = Molecule( + name="reac", + charge=-1, + mult=1, + atoms=[ + Atom("F", -6.16710, 4.34010, 0.14884), + Atom("Cl", -0.96539, 4.54693, -0.06342), + Atom("C", -2.73988, 4.47792, 0.00591), + Atom("H", -3.09044, 3.88869, -0.83567), + Atom("H", -3.02574, 4.01317, 0.94432), + Atom("H", -3.12217, 5.49228, -0.05113), + ], + ) + + # Construct the 2D PES from the current C-F and C-Cl distances to the + # ones at the product, hopefully over the TS + pes = RelaxedPESnD( + species=reac, rs={(0, 2): (1.45, 10), (2, 1): (2.5, 10)} + ) + + pes.load("sn2_pes.npz") + + ts_guesses = list(pes.ts_guesses()) + assert len(ts_guesses) == 1 + + ts_guess = ts_guesses[0] + assert np.isclose(ts_guess.distance(0, 2).to("Å"), 1.891, atol=1e-3) # C-F + + assert np.isclose( + ts_guess.distance(2, 1).to("Å"), 2.179, atol=1e-3 + ) # C-Cl + + +@testutils.work_in_zipped_dir(os.path.join(here, "data.zip")) +def test_da_ts_guesses(): + cyclohexene = Molecule( + atoms=[ + Atom("C", -1.25524, 0.55843, -0.45127), + Atom("C", -0.11901, 1.52914, -0.34083), + Atom("C", 1.12810, 1.05947, -0.22343), + Atom("C", 1.36167, -0.42098, -0.26242), + Atom("C", 0.31173, -1.21999, 0.53472), + Atom("C", -1.08411, -0.56797, 0.57151), + Atom("H", -1.28068, 0.12348, -1.46969), + Atom("H", -2.22404, 1.06103, -0.30930), + Atom("H", -0.31910, 2.60539, -0.34475), + Atom("H", 1.98105, 1.73907, -0.13515), + Atom("H", 2.37438, -0.67767, 0.08526), + Atom("H", 1.32299, -0.74206, -1.32088), + Atom("H", 0.25137, -2.23441, 0.11110), + Atom("H", 0.67503, -1.34778, 1.56617), + Atom("H", -1.86306, -1.33147, 0.42039), + Atom("H", -1.26117, -0.13357, 1.56876), + ] + ) + + pes = RelaxedPESnD( + species=cyclohexene, + rs={(0, 5): (1.45, 3.0, 10), (3, 4): (1.45, 3.0, 10)}, + ) + + pes.load("da_pes.npz") + ts_guesses = list(pes.ts_guesses()) + assert len(ts_guesses) > 0 + + def has_correct_dists(mol): + return np.isclose(mol.distance(0, 5), 2.311, atol=1e-3) and np.isclose( + mol.distance(3, 4), 2.311, atol=1e-3 + ) + + # Diels-Alder TS should be symmetric, and for this surface the bond lengths + # ~2.3 Å + assert any(has_correct_dists(ts_guess) for ts_guess in ts_guesses) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data.zip")) +def test_1d_pes_acetone_cn(): + species = Molecule("acetone_cn.xyz", charge=-1, solvent_name="water") + pes = RelaxedPESnD(species=species, rs={(1, 10): (1.5, 15)}) + + pes.load("acetone_cn.npz") + + # Should only have a single TS guess on the surface + ts_guesses = list(pes.ts_guesses()) + assert len(ts_guesses) == 1 + + ts_guess = ts_guesses[0] # Check the distance is close to the true value + assert np.isclose(ts_guess.distance(1, 10).to("Å"), 1.919, atol=0.1) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data.zip")) +def test_calculating_a_point_with_broken_method_fails(): + orca = ORCA() + orca.path = None + assert not orca.is_available + + species = Molecule("acetone_cn.xyz", charge=-1, solvent_name="water") + pes = RelaxedPESnD(species=species, rs={(1, 10): (1.5, 2)}) + with pytest.raises(RuntimeError): + pes.calculate(method=orca) diff --git a/autodE/source/tests/test_pes/test_rs.py b/autodE/source/tests/test_pes/test_rs.py new file mode 100644 index 0000000000000000000000000000000000000000..3fbd7e4685734ca064bbab9a682bdb876bc0fe75 --- /dev/null +++ b/autodE/source/tests/test_pes/test_rs.py @@ -0,0 +1,211 @@ +import pytest +import numpy as np +from autode.units import ang +from autode.species import Molecule +from autode.values import Energy +from autode.atoms import Atom +from autode.pes.pes_nd import _Distances1D +from autode.pes import pes_nd + + +class PESnD(pes_nd.PESnD): + __test__ = False + + def _calculate(self): + raise NotImplementedError + + def _default_keywords(self, method): + raise NotImplementedError + + @property + def _default_keyword_type(self): + raise NotImplementedError + + +def test_distances1d(): + rs = _Distances1D([0.1, 0.2, -0.1], atom_idxs=(0, 1)) + + assert np.isclose(rs.min, -0.1) + assert np.isclose(rs.max, 0.2) + assert "dist" in repr(rs).lower() + + # Distances default to angstrom units + assert rs.units == ang + + # Distances can be empty + rs = _Distances1D([], atom_idxs=(0, 1)) + assert len(rs) == 0 + + # but cannot have negative atom indexes + with pytest.raises(ValueError): + _Distances1D([], atom_idxs=(-1, 1)) + + # or not have two atom indices + with pytest.raises(ValueError): + _Distances1D([], atom_idxs=(0,)) + + # or have non-integer types + with pytest.raises(ValueError): + _Distances1D([], atom_idxs=(0.1, 1)) + + +def test_pes_nd_attrs(): + pes = PESnD() + + # Empty PES has an empty tuple for a shape + assert pes.shape == tuple() + + assert "pes" in repr(pes).lower() + assert "pes" in repr(pes._energies).lower() + + +def test_pes_nd_rs_init(): + # For a step-size of 0.1 Å there should be 10 steps in a single dimension + pes = PESnD(rs={(0, 1): (1.0, 2.0, 0.11)}) + assert pes.shape == (10,) + + # Defining the number of steps should be equivalent + pes = PESnD(rs={(0, 1): (1.0, 2.0, 10)}) + assert pes.shape == (10,) + + # As is defining the array directly + pes = PESnD(rs={(0, 1): np.linspace(1.0, 2.0, num=10)}) + assert pes.shape == (10,) + + # while defining a a non-integer number raises a value error if no steps + # are going to be performed + with pytest.raises(ValueError): + _ = PESnD(rs={(0, 1): (1.0, 2.0, 10.1)}) + + # or if only a single step is to be performed + with pytest.raises(ValueError): + _ = PESnD(rs={(0, 1): (1.0, 2.0, 1)}) + + # or if there is only a float as the value + with pytest.raises(ValueError): + _ = PESnD(rs={(0, 1): 1.0}) + + # or if the tuple has <2 or >3 elements + with pytest.raises(ValueError): + _ = PESnD(rs={(0, 1): (1.0,)}) + + with pytest.raises(ValueError): + _ = PESnD(rs={(0, 1): (1.0, 2.0, 10, 0.11)}) + + # or the final element in the tuple is not an int or float + with pytest.raises(ValueError): + _ = PESnD(rs={(0, 1): (1.0, "a")}) + + with pytest.raises(ValueError): + _ = PESnD(rs={(0, 1): (1.0, 2.0, "a")}) + + +def test_pes_nd_rs_species_init(): + # Defining only the final distance and step size is + # not supported without a species + with pytest.raises(ValueError): + _ = PESnD(rs={(0, 1): (2.0, 0.1)}) + + # or the number of steps + with pytest.raises(ValueError): + _ = PESnD(rs={(0, 1): (2.0, 10)}) + + # It is possible when a species is defined + h2 = Molecule(atoms=[Atom("H"), Atom("H", x=1.0)]) + + pes = PESnD(species=h2, rs={(0, 1): (1.4, 0.1)}) + assert pes.shape == (5,) + + pes = PESnD(species=h2, rs={(0, 1): (1.4, 5)}) + assert pes.shape == (5,) + + # negative increments give the same behaviour + pes = PESnD(species=h2, rs={(0, 1): (0.6, -0.1)}) + assert pes.shape == (5,) + + # the atom indices must be in the molecule + with pytest.raises(ValueError): + _ = PESnD(rs={(0, 2): (1.4, 5)}) + + # and the number still be positive for negative steps + assert PESnD(species=h2, rs={(0, 1): (0.6, 5)}).shape == (5,) + assert PESnD(species=h2, rs={(0, 1): (0.6, 0.1)}).shape == (5,) + + +def test_pes_nd_rs_rounding(): + pes = PESnD(rs={(0, 1): (1.0, 1.87, 0.1)}, allow_rounding=True) + assert pes.shape == (10,) + + # If rounding is on then the step size should be rounded to support + # an integer number of steps + r1_arr = pes._rs[0] + assert not np.isclose(r1_arr[0] - r1_arr[1], 0.1, atol=1e-10) + + # but if rounding is not allowed then the final point should be + # shifted + pes = PESnD(rs={(0, 1): (1.0, 1.87, 0.1)}, allow_rounding=False) + + # no rounding means there are 11 steps from 1.0 to 2.0 in 0.1 Å steps + assert pes.shape == (10,) + + r1_arr = pes._rs[0] + # Final point should be rounded + assert np.isclose(r1_arr[-1], 1.9, atol=1e-10) + + # and the step size fixed + assert np.isclose(r1_arr[1] - r1_arr[0], 0.1, atol=1e-10) + + +def test_mesh(): + pes = PESnD(rs={(0, 1): (0.1, 0.3, 0.1), (1, 2): (0.1, 0.3, 0.1)}) + assert pes.shape == (3, 3) + + assert hasattr(pes, "r1") + assert hasattr(pes, "r2") + + assert np.allclose(pes.r1[0, 0], 0.1, atol=1e-10) + assert np.allclose(pes.r2[0, 0], 0.1, atol=1e-10) + + # Second item in the matrix should modify r1 + # but leave unchanged r2 (row) i.e. the array be + """ + r2 + ---------------------- + | (0, 0) (0, 1) .. + | (1, 0) . + r1 | . . + | + + """ + assert np.allclose(pes.r1[0, 1], 0.1, atol=1e-10) + assert np.allclose(pes.r2[0, 1], 0.2, atol=1e-10) + + +def test_unset_values(): + pes = PESnD(rs={(0, 1): (0.1, 0.3, 3), (1, 2): (0.1, 0.3, 3)}) + + # All elements on a non-calculated surface are initialised to nan + for i in range(3): + for j in range(3): + assert np.isnan(pes[i, j]) + + assert isinstance(pes[0, 0], Energy) + + +def test_list_distances_1d_equality(): + dists1 = pes_nd._ListDistances1D( + species=Molecule(), rs_dict={}, allow_rounding=False + ) + + dists2 = pes_nd._ListDistances1D( + species=Molecule(), rs_dict={}, allow_rounding=False + ) + + assert not dists1 == "a" + assert dists1 == dists2 + + h2 = Molecule(atoms=[Atom("H"), Atom("H", x=0.7)]) + dists3 = pes_nd._ListDistances1D( + species=h2, rs_dict={(0, 1): (1.0, 5)}, allow_rounding=False + ) + assert not dists1 == dists3 diff --git a/autodE/source/tests/test_pes/test_unrelaxed.py b/autodE/source/tests/test_pes/test_unrelaxed.py new file mode 100644 index 0000000000000000000000000000000000000000..87edff1fd40eaa9851f2db8a872987fcfe1888f8 --- /dev/null +++ b/autodE/source/tests/test_pes/test_unrelaxed.py @@ -0,0 +1,78 @@ +import pytest +import numpy as np +from .. import testutils +from autode.utils import work_in_tmp_dir +from autode.species.molecule import Molecule +from autode.wrappers.XTB import XTB +from autode.wrappers.keywords import SinglePointKeywords +from autode.atoms import Atom +from autode.pes.unrelaxed import UnRelaxedPES1D + + +@testutils.requires_working_xtb_install +@work_in_tmp_dir() +def test_h2_points(): + h2 = Molecule(atoms=[Atom("H"), Atom("H", x=0.8)]) + + pes = UnRelaxedPES1D(h2, rs={(0, 1): (1.5, 10)}) # -> 1.5 Å in 10 steps + + pes.calculate(method=XTB()) + + # Energy should be monotonic increasing over H2 bond length expansion + for n in range(1, pes.shape[0]): + assert pes[n] > pes[n - 1] + + +def test_species_at(): + h2 = Molecule(atoms=[Atom("H"), Atom("H", x=0.8)]) + + pes = UnRelaxedPES1D(h2, rs={(0, 1): (1.5, 3)}) + pes._init_tensors() + + h2_final = pes._species_at(point=(2,)) + assert np.isclose(h2_final.distance(0, 1), 1.5, atol=1e-6) + + +def test_non_bond_unrelaxed(): + h2o = Molecule(atoms=[Atom("O"), Atom("H", x=-0.9), Atom("H", x=0.9)]) + + pes = UnRelaxedPES1D(h2o, rs={(1, 2): (3.0, 3)}) + pes._init_tensors() + + # Cannot pass the checks if the scanned distance is not a bond + with pytest.raises(ValueError): + pes._check() + + +def test_unrelaxed_kwd_type(): + pes = UnRelaxedPES1D( + Molecule(atoms=[Atom("H"), Atom("H", x=0.8)]), rs={(0, 1): (1.5, 10)} + ) + + assert isinstance(pes._default_keyword_type("a str"), SinglePointKeywords) + + +@testutils.requires_working_xtb_install +def test_unrelaxed_must_be_over_a_single_dim(): + h3 = Molecule(atoms=[Atom("H"), Atom("H", x=0.8), Atom("H", x=-0.8)]) + + pes = UnRelaxedPES1D(h3, rs={(0, 1): (1.5, 10), (1, 2): (1.5, 10)}) + + with pytest.raises(NotImplementedError): + pes.calculate(method=XTB()) + + +@testutils.requires_working_xtb_install +@work_in_tmp_dir() +def test_single_energy_works(): + # This method is called but in parallel so codecov can't see it + + h2 = Molecule(atoms=[Atom("H"), Atom("H", x=0.8)]) + + pes = UnRelaxedPES1D(h2, rs={(0, 1): (0.9, 2)}) + xtb = XTB() + pes._method = xtb + pes._keywords = xtb.keywords.sp + result = pes._single_energy(h2, n_cores=1) + + assert result is not None and result != np.nan diff --git a/autodE/source/tests/test_plotting.py b/autodE/source/tests/test_plotting.py new file mode 100644 index 0000000000000000000000000000000000000000..e5a0a999bc79d86556c735a34a6d2a1691b27e3c --- /dev/null +++ b/autodE/source/tests/test_plotting.py @@ -0,0 +1,241 @@ +from autode import plotting +import matplotlib.pyplot as plt +from autode.input_output import xyz_file_to_atoms +from autode.exceptions import CouldNotPlotSmoothProfile +from autode.species.molecule import Reactant, Product +from autode.calculations import Calculation +from autode.methods import ORCA +from autode.transition_states.transition_state import TransitionState +from autode.species.complex import ReactantComplex, ProductComplex +from autode.reactions.reaction import Reaction +from autode.bond_rearrangement import BondRearrangement +from autode.transition_states.ts_guess import TSguess +from autode.units import KjMol, KcalMol +from autode.utils import work_in_tmp_dir +from autode.opt.optimisers.base import OptimiserHistory +from autode.opt.coordinates import CartesianCoordinates +from autode.config import Config +from copy import deepcopy +from scipy.optimize import minimize +from scipy import interpolate +from . import testutils +import numpy as np +import pytest +import os + +here = os.path.dirname(os.path.abspath(__file__)) + + +def test_plot_reaction_profile(): + r = Reactant(name="reactant", smiles="C") + p = Product(name="product", smiles="C") + tsguess = TSguess( + atoms=r.atoms, reactant=ReactantComplex(r), product=ProductComplex(p) + ) + tsguess.bond_rearrangement = BondRearrangement() + ts = TransitionState(tsguess) + reaction = Reaction(r, p) + reaction.ts = ts + + plotting.plot_reaction_profile( + reactions=[reaction], units=KjMol, name="test" + ) + + assert os.path.exists("test_reaction_profile.pdf") + os.remove("test_reaction_profile.pdf") + + with pytest.raises(AssertionError): + plotting.plot_reaction_profile( + reactions=[reaction], + units=KjMol, + name="test", + free_energy=True, + enthalpy=True, + ) + return None + + +def test_stat_points(): + # y = (x-2)^2 has a stationary point at x = 2 + + stationary_points = plotting.get_stationary_points( + xs=np.linspace(-1, 3, 100), dydx=lambda x: 2 * (x - 2) + ) + + assert len(stationary_points) == 1 + assert 1.9 < stationary_points[0] < 2.1 + + +def test_error_on_stat_points(): + energies = np.array([0, 10, 0]) + + # Symmetric energy array shpuld give very low difference between the + # required energies and those obtained at the splined stationary points + assert plotting.error_on_stationary_points(energies, energies) < 1e-3 + + +def test_calculate_reaction_profile_energies(): + test_reac = Reactant(name="test", smiles="C") + test_reac.energy = -1 + + test_prod = Product(name="test", smiles="C") + test_prod.energy = -1.03187251 + + tsguess = TSguess( + atoms=test_reac.atoms, + reactant=ReactantComplex(test_reac), + product=ProductComplex(), + ) + + tsguess.bond_rearrangement = BondRearrangement() + ts = TransitionState(tsguess) + ts.energy = -0.96812749 + + reaction = Reaction(test_reac, test_prod) + reaction.ts = ts + + energies = plotting.calculate_reaction_profile_energies( + reactions=[reaction], units=KcalMol + ) + + # Energies have been set to ∆E = -20 and ∆E‡ = 20 kcal mol-1 respectively + assert energies[0] == 0 + assert 19 < energies[1] < 21 + assert -21 < energies[2] < -19 + + # Copying the reaction should give relative energies [0, 20, -20, 0, -40] + + energies = plotting.calculate_reaction_profile_energies( + reactions=[reaction, deepcopy(reaction)], units=KcalMol + ) + + # Energies have been set to ∆E = -20 and ∆E‡ = 20 kcal mol-1 respectively + assert energies[0] == 0 + assert -0.1 < energies[3] < 0.1 + assert -41 < energies[4] < -39 + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "plotting.zip")) +def test_reaction_warnings(): + test_reac = Reactant(name="test", smiles="C") + test_reac.energy = -1 + + test_prod = Product(name="test", smiles="C") + test_prod.energy = -1.03187251 + + tsguess = TSguess( + atoms=test_reac.atoms, + reactant=ReactantComplex(test_reac), + product=ProductComplex(), + ) + tsguess.bond_rearrangement = BondRearrangement() + ts = TransitionState(tsguess) + ts.energy = -0.98 + + reaction = Reaction(test_reac, test_prod) + reaction.ts = None + + # Should be some warning with no TS + warning_str = plotting.get_reaction_profile_warnings(reactions=[reaction]) + assert len(warning_str) > 10 + assert "barrierless" in warning_str + + # Should be no warnings with a TS that exists and has an energy and one + # imaginary freq + ts.atoms = xyz_file_to_atoms("TS.xyz") + ts.charge = -1 + orca = ORCA() + ts_calc = Calculation( + name="TS", molecule=ts, method=orca, keywords=orca.keywords.opt_ts + ) + ts_calc.set_output_filename("TS.out") + reaction.ts = ts + warnings = plotting.get_reaction_profile_warnings(reactions=[reaction]) + assert "None" in warnings + + +def test_edge_case_plot(): + # Some inputs cannot be plotted as a smooth profile as optimisation of the + # energies to get the correct stationary values removes some stationary + # points + + with pytest.raises(CouldNotPlotSmoothProfile): + energies = np.array([0.0, 4.0, 0.05, -16, 0.3]) + fig, ax = plt.subplots() + + plotting.plot_smooth_profile( + zi_s=np.array([0, 1, 2, 3, 4]), energies=energies, ax=ax + ) + + # But should be able to just plot the points connected by lines + fig, ax = plt.subplots() + plotting.plot_points( + zi_s=np.array([0, 1, 2, 3, 4]), energies=energies, ax=ax + ) + plt.close() + + +def test_stat_point_minimisation(): + # Test that the minimisation works for very shallow minima + + energies_list = [ + np.array([0.0, 3.8, -9.1, -1.6, 0.3]), + np.array([0.0, 10, -20, 10, -5]), + ] + + for energies in energies_list: + result = minimize( + plotting.error_on_stationary_points, + x0=energies, + args=(energies,), + method="BFGS", + tol=0.1, + ) + + assert result.success + + spline = interpolate.CubicSpline( + [0, 1, 2, 3, 4], result.x, bc_type="clamped" + ) + fine_zi_s = np.linspace(-0.2, 5.2, num=500) + stationary_points = plotting.get_stationary_points( + xs=fine_zi_s, dydx=spline.derivative() + ) + assert len(stationary_points) == 5 + + +def test_energy(): + energy = plotting.Energy(5, units="Ha", estimated=False) + assert not energy.is_estimated + assert energy == 5 + + new_energy = energy * 5 + assert new_energy == 25 + assert not new_energy.is_estimated + + new_energy = energy - 5 + assert new_energy == 0 + + energy2 = plotting.Energy(2, units="Ha", estimated=False) + new_energy = 5 * energy2 + assert new_energy == 10 + + assert "energy" in repr(energy).lower() + + +@work_in_tmp_dir() +def test_optimiser_plot(): + hist = OptimiserHistory() + x = CartesianCoordinates(np.arange(6)) + x.e = -1.45 + x.g = np.array([1.0, 0.9, 1.2, 0.4, 3.4, 0.3]) + x2 = x.copy() + hist.add(x) + hist.add(x2) + plotting.plot_optimiser_profile( + history=hist, + plot_energy=True, + plot_rms_grad=True, + filename="this_file.pdf", + ) + assert os.path.isfile("this_file.pdf") diff --git a/autodE/source/tests/test_point_charge.py b/autodE/source/tests/test_point_charge.py new file mode 100644 index 0000000000000000000000000000000000000000..0462b46f1beab89d92cbab5df5cab3c9a3b3d82e --- /dev/null +++ b/autodE/source/tests/test_point_charge.py @@ -0,0 +1,26 @@ +import pytest +import numpy as np +from autode.point_charges import PointCharge + + +def test_pc(): + pc = PointCharge(1.0) + # Should initialise close to the origin + assert np.linalg.norm(pc.coord) < 1e-6 + + # and should be translatable + pc.translate(1.0, 0.0, 0.0) + assert np.linalg.norm(pc.coord - np.array([1.0, 0.0, 0.0])) < 1e-6 + + # Should have the assigned charge (units of e) + assert np.isclose(pc.charge, 1.0) + + # and be initialisable from a coord (for backwards compatibility) + coord = np.array([-1.0, 0.0, 0.0]) + pc_from_coord = PointCharge(1.0, coord=coord) + assert np.linalg.norm(pc_from_coord.coord - coord) < 1e-6 + + +def test_pc_wrong_shape_coord(): + with pytest.raises(Exception): + _ = PointCharge(charge=0, coord=np.zeros(4)) diff --git a/autodE/source/tests/test_qrc.py b/autodE/source/tests/test_qrc.py new file mode 100644 index 0000000000000000000000000000000000000000..9d17ebf54e0e8e7d07ded753805f6f7e55895d94 --- /dev/null +++ b/autodE/source/tests/test_qrc.py @@ -0,0 +1,44 @@ +import os +import numpy as np +from autode.calculations import Calculation +from autode.species import Reactant, Molecule +from autode.methods import ORCA +from autode.transition_states.base import displaced_species_along_mode +from . import testutils + +here = os.path.dirname(os.path.abspath(__file__)) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "qrc.zip")) +def test_hshift_displacement(): + orca = ORCA() + + ts = Reactant(name="reactant", smiles="CC[C]([H])[H]") + calc = Calculation( + name="tmp", molecule=ts, method=orca, keywords=orca.keywords.opt_ts + ) + calc.set_output_filename("TS_hshift.out") + + assert calc.terminated_normally + assert ts.hessian is not None + + f_disp_mol = displaced_species_along_mode( + ts, mode_number=6, disp_factor=1.0 # TS mode + ) + + # Maximum displacement of an atom is large for this H=atom shift + assert ( + np.max(np.linalg.norm(ts.coordinates - f_disp_mol.coordinates, axis=1)) + > 0.5 + ) + + # applying such a large shift may lead to unphysical geometries, thus + # use a maximum scale factor + f_disp_mol = displaced_species_along_mode( + ts, mode_number=6, disp_factor=1.0, max_atom_disp=0.1 # TS mode + ) + + max_disp = np.max( + np.linalg.norm(ts.coordinates - f_disp_mol.coordinates, axis=1) + ) + assert 0.05 < max_disp < 0.15 diff --git a/autodE/source/tests/test_rb_min.py b/autodE/source/tests/test_rb_min.py new file mode 100644 index 0000000000000000000000000000000000000000..b38b00240de48e29c7e8be8c5b0ec5f97cf5319a --- /dev/null +++ b/autodE/source/tests/test_rb_min.py @@ -0,0 +1,54 @@ +import numpy as np +from autode import Molecule +from autode.atoms import Atom +from ade_rb_opt import opt_rb_coords +from autode.geom import are_coords_reasonable + + +def rb_minimised_is_reasonable(molecule): + n_atoms = molecule.n_atoms + coords = opt_rb_coords( + py_coords=molecule.coordinates, + py_bonded_matrix=molecule.bond_matrix, + py_r0_matrix=np.asarray( + molecule.graph.eqm_bond_distance_matrix, dtype="f8" + ), + py_k_matrix=1 * np.ones((n_atoms, n_atoms), dtype="f8"), + py_c_matrix=0.01 * np.ones((n_atoms, n_atoms), dtype="f8"), + py_exponent=8, + ) + + molecule.coordinates = coords + molecule.print_xyz_file(filename="tmp.xyz") + + assert are_coords_reasonable(coords) + + +def test_h2(): + """Simple 1D optimisation of H2, slightly displaced from its minimum""" + + h2 = Molecule(atoms=[Atom("H"), Atom("H", x=1.1)]) + + # Add a bond between the two H atoms + h2.graph.add_edge(0, 1) + + rb_minimised_is_reasonable(h2) + assert np.isclose(h2.distance(0, 1), 0.8, atol=0.1) + + +def test_alkanes(): + rb_minimised_is_reasonable(molecule=Molecule(smiles="C")) + rb_minimised_is_reasonable(molecule=Molecule(smiles="CCCC")) + + +def _test_tmp(): + butane = Molecule(smiles="CCCCCCCC") + + from autode.conformers.conf_gen import get_simanl_conformer + + tmp = get_simanl_conformer(butane, save_xyz=False) + tmp.print_xyz_file(filename="tmp.xyz") + + # butane.populate_conformers(n_confs=5) + # for i, conf in enumerate(butane.conformers): + # conf.print_xyz_file(filename=f'tmp{i}.xyz') diff --git a/autodE/source/tests/test_reaction_class.py b/autodE/source/tests/test_reaction_class.py new file mode 100644 index 0000000000000000000000000000000000000000..a7b5e31cafc1bad61201675e12a8f834a492354f --- /dev/null +++ b/autodE/source/tests/test_reaction_class.py @@ -0,0 +1,561 @@ +import os +import numpy as np +from time import time +from autode.reactions import reaction +from autode.reactions import reaction_types +from autode.transition_states.transition_state import TransitionState +from autode.bond_rearrangement import BondRearrangement +from autode.species import Reactant, Product +from autode.transition_states.ts_guess import TSguess +from autode.species.complex import ReactantComplex, ProductComplex +from autode.atoms import Atom +from autode.exceptions import UnbalancedReaction +from autode.exceptions import SolventsDontMatch +from autode.mol_graphs import make_graph +from autode.plotting import plot_reaction_profile +from autode.units import KcalMol +from autode.values import ( + PotentialEnergy, + FreeEnergy, + Enthalpy, + EnthalpyCont, + FreeEnergyCont, +) +from autode.methods import get_hmethod +from autode.config import Config +from .testutils import work_in_zipped_dir, requires_working_xtb_install +import pytest + +here = os.path.dirname(os.path.abspath(__file__)) + +# Spoof ORCA install +Config.hcode = "ORCA" +Config.ORCA.path = here + +h1 = Reactant(name="h1", atoms=[Atom("H", 0.0, 0.0, 0.0)]) + +h2 = Reactant(name="h2", atoms=[Atom("H", 1.0, 0.0, 0.0)]) +h2_product = Product(name="h2", atoms=[Atom("H", 1.0, 0.0, 0.0)]) + +lin_h3 = Reactant( + name="h3_linear", + atoms=[ + Atom("H", -1.76172, 0.79084, -0.00832), + Atom("H", -2.13052, 0.18085, 0.00494), + Atom("H", -1.39867, 1.39880, -0.00676), + ], +) + +trig_h3 = Product( + name="h3_trigonal", + atoms=[ + Atom("H", -1.76172, 0.79084, -0.00832), + Atom("H", -1.65980, 1.15506, 0.61469), + Atom("H", -1.39867, 1.39880, -0.00676), + ], +) + + +def test_reaction_class(): + h1 = reaction.Reactant(name="h1", atoms=[Atom("H", 0.0, 0.0, 0.0)]) + hh_product = reaction.Product( + name="hh", atoms=[Atom("H", 0.0, 0.0, 0.0), Atom("H", 0.7, 0.0, 0.0)] + ) + + # h + h > mol + hh_reac = reaction.Reaction(h1, h2, hh_product, name="h2_assoc") + + h1.energy = 2 + h2.energy = 3 + hh_product.energy = 1 + + assert hh_reac.atomic_symbols == ["H", "H"] + + # Only swap to dissociation in invoking locate_ts() + assert hh_reac.type == reaction_types.Addition + assert len(hh_reac.prods) == 1 + assert len(hh_reac.reacs) == 2 + assert hh_reac.ts is None + assert len(hh_reac.tss) == 0 + assert hh_reac.name == "h2_assoc" + assert hh_reac.delta("E") == PotentialEnergy(-4.0) + + h1 = reaction.Reactant(name="h1", atoms=[Atom("H")]) + hh_reactant = reaction.Reactant( + name="hh", atoms=[Atom("H"), Atom("H", x=1.0)] + ) + hh_product = reaction.Product( + name="hh", atoms=[Atom("H"), Atom("H", x=1.0)] + ) + + # h + mol > mol + h + h_sub = reaction.Reaction( + h1, hh_reactant, h2_product, hh_product, solvent_name="water" + ) + + assert h_sub.type == reaction_types.Substitution + assert h_sub.name == "reaction" + assert h_sub.solvent.name == "water" + assert h_sub.solvent.smiles == "O" + for mol in h_sub.reacs + h_sub.prods: + assert mol.solvent.name == "water" + + # Must set the transition state with a TransitionState + with pytest.raises(ValueError): + h_sub.ts = h1 + + +def test_reactant_product_complexes(): + h2_prod = Product(name="h2", atoms=[Atom("H"), Atom("H", x=1.0)]) + + rxn = reaction.Reaction(h1, h2, h2_prod) + assert rxn.reactant.n_molecules == 2 + assert rxn.reactant.distance(0, 1) > 1 + + assert rxn.product.n_molecules == 1 + + # If the reactant complex is set then the whole reactant should be that + rxn.reactant = ReactantComplex( + h1, h1, copy=True, do_init_translation=False + ) + assert -1e-4 < rxn.reactant.distance(0, 1) < 1e-4 + + # but cannot be just a reactant + with pytest.raises(ValueError): + rxn.reactant = h1 + + # and similarly with the products + with pytest.raises(ValueError): + rxn.product = h2 + + # but can set the product complex + rxn.product = ProductComplex( + Product(atoms=[Atom("H"), Atom("H", x=1.0)]), name="tmp" + ) + assert rxn.product.name == "tmp" + + +def test_invalid_with_complexes(): + Config.hcode = "ORCA" + Config.ORCA.path = here + + h3_reaction = reaction.Reaction(lin_h3, trig_h3) + + # Currently free energies with association complexes is not supported + with pytest.raises(NotImplementedError): + h3_reaction.calculate_reaction_profile( + with_complexes=True, free_energy=True + ) + + # Cannot plot a reaction profile with complexes without them existing + with pytest.raises(ValueError): + h3_reaction._plot_reaction_profile_with_complexes( + units=KcalMol, free_energy=False, enthalpy=False + ) + + +def test_check_rearrangement(): + # Linear H3 -> Trigonal H3 + make_graph(species=trig_h3, allow_invalid_valancies=True) + reac = reaction.Reaction(lin_h3, trig_h3) + + # Should switch reactants and products if the products have more bonds than + # the reactants, but only when the TS is attempted to be located.. + + # assert reac.reacs[0].name == 'h3_trigonal' + # assert reac.prods[0].name == 'h3_linear' + + +def test_check_solvent(): + r = Reactant(name="r", solvent_name="water") + p = Product(name="p") + + with pytest.raises(SolventsDontMatch): + _ = reaction.Reaction(r, p) + + p = Product(name="p", solvent_name="water") + reaction_check = reaction.Reaction(r, p) + assert reaction_check.solvent.name == "water" + + +def test_reaction_identical_reac_prods(): + Config.hcode = "ORCA" + Config.ORCA.path = here + + hh_reactant = reaction.Reactant( + name="hh", atoms=[Atom("H"), Atom("H", x=1.0)] + ) + hh_product = reaction.Product( + name="hh", atoms=[Atom("H"), Atom("H", x=1.0)] + ) + + h2_reaction = reaction.Reaction(hh_reactant, hh_product) + + with pytest.raises(ValueError): + h2_reaction.locate_transition_state() + + +def test_swap_reacs_prods(): + reactant = Reactant(name="r") + product = Product(name="p") + + swapped_reaction = reaction.Reaction(reactant, product) + assert swapped_reaction.reacs[0].name == "r" + assert swapped_reaction.prods[0].name == "p" + + swapped_reaction.switch_reactants_products() + assert swapped_reaction.reacs[0].name == "p" + assert swapped_reaction.prods[0].name == "r" + + +def test_bad_balance(): + hh_product = reaction.Product( + name="hh", atoms=[Atom("H"), Atom("H", x=1.0)] + ) + + with pytest.raises(UnbalancedReaction): + reaction.Reaction(h1, hh_product) + + h_minus = reaction.Reactant(name="h1_minus", atoms=[Atom("H")], charge=-1) + with pytest.raises(UnbalancedReaction): + reaction.Reaction(h1, h_minus, hh_product) + + h1_water = reaction.Reactant( + name="h1", atoms=[Atom("H")], solvent_name="water" + ) + h2_water = reaction.Reactant( + name="h2", atoms=[Atom("H", x=1.0)], solvent_name="water" + ) + hh_thf = reaction.Product( + name="hh", atoms=[Atom("H"), Atom("H", x=1.0)], solvent_name="thf" + ) + + with pytest.raises(SolventsDontMatch): + reaction.Reaction(h1_water, h2_water, hh_thf) + + with pytest.raises(NotImplementedError): + hh_triplet = reaction.Product( + name="hh_trip", atoms=[Atom("H"), Atom("H", x=0.7)], mult=3 + ) + reaction.Reaction(h1, h2, hh_triplet) + + +def test_calc_delta_e(): + r1 = reaction.Reactant(name="h", atoms=[Atom("H")]) + r1.energy = -0.5 + + r2 = reaction.Reactant(name="h", atoms=[Atom("H")]) + r2.energy = -0.5 + + reac_complex = ReactantComplex(r1) + assert reac_complex.graph is not None + + tsguess = TSguess( + atoms=reac_complex.atoms, + reactant=reac_complex, + product=ProductComplex(r2.to_product()), + ) + + tsguess.bond_rearrangement = BondRearrangement() + ts = TransitionState(tsguess) + ts.energy = -0.8 + + p = reaction.Product(name="hh", atoms=[Atom("H"), Atom("H", x=1.0)]) + p.energy = -1.0 + + reac = reaction.Reaction(r1, r2, p) + reac.ts = ts + + assert -1e-6 < reac.delta("E") < 1e-6 + assert 0.2 - 1e-6 < reac.delta("E‡") < 0.2 + 1e-6 + + +def test_from_smiles(): + # Chemdraw can generate a reaction with reactants and products + addition = reaction.Reaction(smiles="CC(C)=O.[C-]#N>>CC([O-])(C#N)C") + + assert len(addition.reacs) == 2 + assert len(addition.prods) == 1 + + # Should be readable-ish names + for reac in addition.reacs: + assert reac.name != "molecule" + + with pytest.raises(UnbalancedReaction): + _ = reaction.Reaction("CC(C)=O.[C-]#N") + + +def test_single_points(): + # Spoof ORCA install + Config.ORCA.path = here + + rxn = reaction.Reaction(Reactant(smiles="O"), Product(smiles="O")) + + # calculate_single_points should be pretty tolerant.. not raising + # exceptions if the energy is already None + rxn.calculate_single_points() + assert rxn.reacs[0].energy is None + + overlapping_h2 = Reactant(atoms=[Atom("H"), Atom("H")]) + overlapping_h2.energy = -1 + rxn.reacs = [overlapping_h2] + + # Shouldn't calculate a single point for a molecule that is not + # 'reasonable' + rxn.calculate_single_points() + assert rxn.reacs[0].energy == -1 + + Config.ORCA.path = None + + +@work_in_zipped_dir(os.path.join(here, "data", "free_energy_profile.zip")) +@requires_working_xtb_install +def test_free_energy_profile(): + # Use a spoofed Gaussian09 and XTB install + Config.lcode = "xtb" + + Config.hcode = "g09" + Config.G09.path = here + + Config.ts_template_folder_path = os.getcwd() + Config.hmethod_conformers = False + Config.standard_state = "1atm" + Config.lfm_method = "igm" + + method = get_hmethod() + assert method.name == "g09" + assert method.is_available + + rxn = reaction.Reaction( + Reactant(name="F-", smiles="[F-]"), + Reactant(name="CH3Cl", smiles="ClC"), + Product(name="Cl-", smiles="[Cl-]"), + Product(name="CH3F", smiles="CF"), + name="sn2", + solvent_name="water", + ) + + start_time = time() + rxn.calculate_reaction_profile(free_energy=True) + full_calc_reaction_profile_time = time() - start_time + rxn.save("tmp.chk") + + # Allow ~0.5 kcal mol-1 either side of the 'true' value + + assert 16 < rxn.delta("G‡").to("kcal mol-1") < 18 + assert -14 < rxn.delta("G").to("kcal mol-1") < -12 + + assert 9 < rxn.delta("H‡").to("kcal mol-1") < 11 + assert -14 < rxn.delta("H").to("kcal mol-1") < -12 + + # Should be able to plot an enthalpy profile + plot_reaction_profile([rxn], units=KcalMol, name="enthalpy", enthalpy=True) + assert os.path.exists("enthalpy_reaction_profile.pdf") + os.remove("enthalpy_reaction_profile.pdf") + + # Rerunning the reaction should be fast + start_time = time() + rxn.calculate_reaction_profile(free_energy=True) + assert time() - start_time < full_calc_reaction_profile_time / 2 + + # Should be able to reload the entire reaction state + reloaded_rxn = reaction.Reaction.from_checkpoint("tmp.chk") + assert reloaded_rxn.ts is not None + + # Reset the configuration to the default values + Config.hcode = None + Config.G09.path = None + Config.lcode = None + Config.XTB.path = None + + +def test_barrierless_rearrangment(): + rxn = reaction.Reaction(Reactant(), Product()) + assert rxn.is_barrierless + assert rxn.delta("E") is rxn.delta("E‡") is None + + a = Reactant(atoms=[Atom("H"), Atom("H", x=-1.0), Atom("H", x=1.0)]) + a.energy = -2.0 + + b = Product(atoms=[Atom("H"), Atom("H", x=0.7, y=0.7), Atom("H", x=1.0)]) + b.energy = -2.5 + + rxn = reaction.Reaction(a, b) + # Barrier should be ~0 for an exothermic reaction + assert rxn.delta("E‡") == 0.0 + + # but the reaction energy for an endothermic reaction + b.energy = -1.5 + assert rxn.delta("E‡") == 0.5 + + +def test_doc_example(): + """If this test changes PLEASE update the documentation at the same time""" + + ethene = Reactant(smiles="C=C") + butadiene = Reactant(smiles="C=CC=C") + cyclohexene = Product(smiles="C1=CCCCC1") + + rxn = reaction.Reaction(ethene, butadiene, cyclohexene) + assert rxn.solvent is None + + assert np.isclose(rxn.temp, 298.15) + + assert rxn.delta("E") is None + assert rxn.delta("E‡") is None + + # Only allow some indication of energy/enthalpy/free energy + with pytest.raises(ValueError): + _ = rxn.delta("X") + + ethene.energy = -6.27126052543 + butadiene.energy = -11.552702027244 + cyclohexene.energy = -17.93143795711 + + assert np.isclose( + float(rxn.delta("E").to("kcal mol-1")), -67.441, atol=0.01 + ) + + # Should allow for aliases of the kind of ∆ difference + assert rxn.delta("E") == rxn.delta("energy") + assert ( + rxn.delta("G") == rxn.delta("free energy") == rxn.delta("free_energy") + ) + assert rxn.delta("H") == rxn.delta("enthalpy") != rxn.delta("energy") + + assert np.isclose(float(rxn.delta("E‡").to("kcal mol-1")), 4.35491, atol=1) + + # Post-optimisation + cyclohexene.energy = -234.206929484613 + butadiene.energy = -155.686225567141 + ethene.energy = -78.427547239225 + + atoms = [ + Atom("C", 1.54832502175757, 0.47507857149246, -0.17608869645477), + Atom("C", 0.63680758724295, 1.48873574610658, 0.18687763919775), + Atom("C", -0.48045277463960, 1.22774144243181, 0.94515144035260), + Atom("C", -1.56617230970127, -0.32194220965435, -0.36403038119916), + Atom("C", -0.67418142609443, -1.30824343582455, -0.72092960944319), + Atom("C", 1.37354885332651, -0.83414272445258, 0.20733555387781), + Atom("H", 2.28799156107427, 0.70407896562267, -0.95011846456488), + Atom("H", 0.71504911441008, 2.45432905429025, -0.32320231835197), + Atom("H", -1.22975529929055, 2.00649252772661, 1.11086944491345), + Atom("H", -0.48335986214289, 0.41671089728999, 1.67449683583301), + Atom("H", -2.28180803085362, -0.49278222876383, 0.44394762543300), + Atom("H", -1.83362998587976, 0.46266072581502, -1.07348798508224), + Atom("H", -0.67829969484189, -2.26880762586176, -0.19982465470916), + Atom("H", -0.22671274865021, -1.31325250991020, -1.71602826881263), + Atom("H", 0.86381884115892, -1.08003298402692, 1.13995512315906), + Atom("H", 2.02879115312393, -1.61656419228120, -0.18499328414869), + ] + + rxn.ts = TransitionState(TSguess(atoms=atoms)) + rxn.ts.energy = -234.090983203239 + + assert "TransitionState" in repr(rxn.ts) + + assert np.isclose(float(rxn.delta("E‡").to("kcal mol-1")), 14.3, atol=0.1) + + +def test_barrierless_h_g(): + a = Reactant(atoms=[Atom("H"), Atom("H", x=-1.0), Atom("H", x=1.0)]) + a.energies.extend( + [PotentialEnergy(-1), EnthalpyCont(0.1), FreeEnergyCont(0.3)] + ) + + b = Product(atoms=[Atom("H"), Atom("H", x=0.7, y=0.7), Atom("H", x=1.0)]) + b.energies.extend( + [PotentialEnergy(-2), EnthalpyCont(0.2), FreeEnergyCont(0.6)] + ) + + rxn = reaction.Reaction(a, b) + assert rxn.delta("E‡") == 0.0 + assert rxn.delta("H‡") == 0.0 + assert rxn.delta("G‡") == 0.0 + + rxn.switch_reactants_products() + assert np.isclose(rxn.delta("E‡"), 1.0) + + assert np.isclose(rxn.delta("H‡"), 0.9) # -2+0.2 -> -1+0.1 --> ∆ = 0.9 + + assert np.isclose(rxn.delta("G‡"), 0.7) # -2+0.6 -> -1+0.3 --> ∆ = 0.7 + + +@pytest.mark.parametrize("energy_syn", ["E", "Energy"]) +@pytest.mark.parametrize("enthalpy_syn", ["H", "Enthalpy"]) +@pytest.mark.parametrize("free_syn", ["G", "free energy", "free_energy"]) +@pytest.mark.parametrize("ts_syn", ["ddagger", "‡", "double dagger"]) +def test_energy_synonyms(energy_syn, enthalpy_syn, free_syn, ts_syn): + a = Reactant(atoms=[Atom("H"), Atom("H", x=0.7, y=0.7), Atom("H", x=1.0)]) + a.energies.extend( + [PotentialEnergy(-2), EnthalpyCont(0.2), FreeEnergyCont(0.6)] + ) + + b = Product(atoms=[Atom("H"), Atom("H", x=-1.0), Atom("H", x=1.0)]) + b.energies.extend( + [PotentialEnergy(-1), EnthalpyCont(0.1), FreeEnergyCont(0.3)] + ) + + rxn = reaction.Reaction(a, b) + # Test potential energies + assert np.isclose(rxn.delta(energy_syn + ts_syn), 1.0) + + # Test enthalpies + assert np.isclose( + rxn.delta(enthalpy_syn + ts_syn), 0.9 + ) # -2+0.2 -> -1+0.1 --> ∆ = 0.9 + + # Test free energies + assert np.isclose( + rxn.delta(free_syn + ts_syn), 0.7 + ) # -2+0.6 -> -1+0.3 --> ∆ = 0.7 + + +def test_same_composition(): + r1 = reaction.Reaction( + Reactant(atoms=[Atom("C"), Atom("H", x=1)]), + Product(atoms=[Atom("C"), Atom("H", x=10)]), + ) + + r2 = reaction.Reaction( + Reactant(atoms=[Atom("C"), Atom("H", x=1)]), + Product(atoms=[Atom("C"), Atom("H", x=5)]), + ) + + assert r1.has_identical_composition_as(r2) + + r3 = reaction.Reaction( + Reactant(name="h2", atoms=[Atom("H", 1.0, 0.0, 0.0)]), + Product(name="h2", atoms=[Atom("H", 1.0, 0.0, 0.0)]), + ) + assert not r1.has_identical_composition_as(r3) + + +def test_name_uniqueness(): + rxn = reaction.Reaction( + Reactant(smiles="CC[C]([H])[H]"), Product(smiles="C[C]([H])C") + ) + + assert rxn.reacs[0].name != rxn.prods[0].name + + +def test_identity_reaction_is_supported_with_labels(): + def reaction_is_isomorphic(_r): + return _r.reactant.graph.is_isomorphic_to(_r.product.graph) + + isomorphic_rxn = reaction.Reaction("[Br-].C[Br]>>C[Br].[Br-]") + assert reaction_is_isomorphic(isomorphic_rxn) + + rxn = reaction.Reaction("[Br-:1].C[Br:2]>>C[Br:1].[Br-:2]") + assert not reaction_is_isomorphic(rxn) + + +def test_cannot_run_locate_ts_with_no_reactants_or_products(): + Config.lcode = Config.hcode = "ORCA" + Config.ORCA.path = here + + rxn = reaction.Reaction() + with pytest.raises(AssertionError): + rxn.locate_transition_state() + + Config.lcode = None diff --git a/autodE/source/tests/test_reaction_with_complexes.py b/autodE/source/tests/test_reaction_with_complexes.py new file mode 100644 index 0000000000000000000000000000000000000000..89cd6b42afad3568eb2a8c26a44cef5e330dd98e --- /dev/null +++ b/autodE/source/tests/test_reaction_with_complexes.py @@ -0,0 +1,39 @@ +import os +import shutil +import autode as ade +from . import testutils + +here = os.path.dirname(os.path.abspath(__file__)) + + +@testutils.work_in_zipped_dir( + os.path.join(here, "data", "reaction_with_complexes.zip") +) +@testutils.requires_working_xtb_install +def test_reaction_w_complexes(): + ade.Config.n_cores = 1 # Ensure only a single core is used + + ade.Config.hcode = "orca" + ade.Config.ORCA.path = here # Spoof ORCA install + + # Ensure no DFT needs to be done other than that saved + ade.Config.num_conformers = 1 + ade.Config.max_num_complex_conformers = 1 + ade.Config.ts_template_folder_path = os.getcwd() + + f = ade.Reactant(name="f", smiles="[F-]") + mecl = ade.Reactant(name="mecl", smiles="ClC") + cl = ade.Product(name="cl", smiles="[Cl-]") + mef = ade.Product(name="mef", smiles="CF") + + rxn = ade.Reaction(f, mecl, cl, mef, solvent_name="water", name="sn2_wc") + rxn.calculate_reaction_profile(with_complexes=True) + + assert rxn.ts is not None + + # Should have a defined energy for the reactant and product complexes + assert rxn.reactant.energy is not None + assert rxn.reactant.n_molecules == 2 + + assert rxn.product.energy is not None + assert rxn.product.n_molecules == 2 diff --git a/autodE/source/tests/test_reactions.py b/autodE/source/tests/test_reactions.py new file mode 100644 index 0000000000000000000000000000000000000000..222cc8f02b3eb61c60fd7c5fccddd0ffc87f52bf --- /dev/null +++ b/autodE/source/tests/test_reactions.py @@ -0,0 +1,43 @@ +import pytest +from autode.reactions.reaction_types import classify, Addition +from autode.exceptions import ReactionFormationFailed + + +def test_classify(): + """ + Testing with integers, as in classify there is no check on the type + of reactants and products, only their length + """ + + addition = classify([0, 0], [0]) + assert addition.name == "addition" + + dissociation = classify([0], [0, 0]) + assert dissociation.name == "dissociation" + + substitution = classify([0, 0], [0, 0]) + assert substitution.name == "substitution" + + elimination = classify([0, 0], [0, 0, 0]) + assert elimination.name == "elimination" + + rearrangement = classify([0], [0]) + assert rearrangement.name == "rearrangement" + + assert classify([], []) is None + + # Needs to have at least some reactants or products + with pytest.raises(ReactionFormationFailed): + _ = classify([0], []) + + with pytest.raises(ReactionFormationFailed): + _ = classify([], [0]) + + # 3 -> 3 reactions are not currently supported + with pytest.raises(NotImplementedError): + _ = classify([0, 1, 2], [3, 4, 5]) + + +def test_equality(): + assert Addition == Addition + assert Addition != 0 diff --git a/autodE/source/tests/test_smiles_base.py b/autodE/source/tests/test_smiles_base.py new file mode 100644 index 0000000000000000000000000000000000000000..1085cd3bc2e76c86da376b1321f2adcb6d39bacb --- /dev/null +++ b/autodE/source/tests/test_smiles_base.py @@ -0,0 +1,46 @@ +import pytest +import autode.exceptions as ex +from autode.smiles.base import ( + SMILESAtom, + SMILESBond, + SMILESStereoChem, + RingBond, +) + + +def test_smiles_atom(): + atom = SMILESAtom("C", stereochem=SMILESStereoChem.TET_NORMAL) + atom.invert_stereochem() + assert atom.stereochem == SMILESStereoChem.TET_INVERTED + + # Invert back + atom.invert_stereochem() + assert atom.stereochem == SMILESStereoChem.TET_NORMAL + + +def test_smiles_bond(): + atoms = [ + SMILESAtom("C", stereochem=SMILESStereoChem.ALKENE_UP), + SMILESAtom("C", stereochem=SMILESStereoChem.ALKENE_UP), + ] + + bond = SMILESBond(0, 1, symbol="=") + assert bond.is_cis(atoms=atoms) + assert not bond.is_trans(atoms=atoms) + + for atom in atoms: + atom.stereochem = None + + # Without stereochemistry the double bond should default to trans + assert not bond.is_cis(atoms=atoms) + assert bond.is_trans(atoms=atoms) + + # Invalid bond symbol + with pytest.raises(ex.InvalidSmilesString): + _ = SMILESBond(0, 1, symbol="--") + + bond = RingBond(0, symbol="=") + bond.close(1, symbol="-") + + assert bond.closes_ring + assert bond.in_ring(rings_idxs=[{0, 1}]) diff --git a/autodE/source/tests/test_smiles_builder.py b/autodE/source/tests/test_smiles_builder.py new file mode 100644 index 0000000000000000000000000000000000000000..386269cda5751208ffe1dc8a6df7d287ecee48e0 --- /dev/null +++ b/autodE/source/tests/test_smiles_builder.py @@ -0,0 +1,688 @@ +import pytest +import numpy as np +from autode import Molecule +from autode.atoms import Atom +from autode.smiles.smiles import init_smiles +from autode.smiles.atom_types import TetrahedralAtom +from autode.geom import are_coords_reasonable, calc_heavy_atom_rmsd +from autode.smiles.parser import Parser, SMILESBonds, RingBond, SMILESAtom +from autode.smiles.builder import Builder, SAngle, SDihedral +from autode.exceptions import SMILESBuildFailed +from autode.mol_graphs import get_mapping + +parser = Parser() +builder = Builder() + + +def built_molecule_is_reasonable(smiles): + """Is the molecule built from a SMILES string sensible?""" + + parser.parse(smiles) + builder.build(parser.atoms, parser.bonds) + mol = Molecule(atoms=builder.atoms) + # mol.print_xyz_file(filename='tmp.xyz') + + return are_coords_reasonable(mol.coordinates) + + +def built_molecule_is_usually_reasonable(smiles, n_trys=3): + """Is building this molecule mostly sensible?""" + + for _ in range(n_trys): + if built_molecule_is_reasonable(smiles): + return True + + return False + + +def test_base_builder(): + # Builder needs SMILESAtom-s + with pytest.raises(SMILESBuildFailed): + builder.build(atoms=[Atom("H")], bonds=SMILESBonds()) + + # Builder needs at least some atoms + with pytest.raises(SMILESBuildFailed): + builder.build(atoms=None, bonds=[]) + + with pytest.raises(SMILESBuildFailed): + builder.build(atoms=[], bonds=[]) + + parser.parse(smiles="O") + builder.build(atoms=parser.atoms, bonds=parser.bonds) + assert builder.non_bonded_idx_matrix.shape == (3, 3) + assert builder.non_bonded_idx_matrix[0, 1] == 0 # O-H + assert builder.non_bonded_idx_matrix[0, 0] == 0 # O-O + assert builder.non_bonded_idx_matrix[1, 2] == 1 # H-H + + for atom in builder.canonical_atoms: + assert isinstance(atom, Atom) + assert hasattr(atom, "coord") + + for atom in builder.canonical_atoms_at_origin: + assert np.isclose(np.linalg.norm(atom.coord), 0.0, atol=1e-4) + + +def test_build_single_atom(): + """No building needed for a single atom, but the builder should be fine""" + + parser.parse(smiles="[H]") + builder.build(atoms=parser.atoms, bonds=parser.bonds) + + assert builder.n_atoms == 1 + assert parser.mult == 2 + + +def test_ring_path(): + parser.parse(smiles="C1C1") + builder.set_atoms_bonds(atoms=parser.atoms, bonds=parser.bonds) + + # Define a 'ring' bond in this system of just two atoms in a 'ring' + ring_bond = RingBond(idx_i=0, symbol="-") + ring_bond.close(idx=1, symbol="-") + + # There then is no valid path that traverses the ring + with pytest.raises(SMILESBuildFailed): + _ = builder._ring_path(ring_bond) + + +def test_too_high_valance(): + parser.parse(smiles="CC(C)(C)(C)(C)(C)(C)(C)(C)C") + + with pytest.raises(Exception): + builder.build(atoms=parser.atoms, bonds=parser.bonds) + + +def test_explicit_hs(): + parser.parse(smiles="C") + builder.set_atoms_bonds(atoms=parser.atoms, bonds=parser.bonds) + + # Should convert all implicit Hs to explicit atoms + assert len(builder.atoms) == 5 + assert len(builder.bonds) == 4 + assert len([atom for atom in builder.atoms if atom.label == "H"]) == 4 + + assert builder.graph.number_of_nodes() == 5 + assert builder.graph.number_of_edges() == 4 + + parser.parse(smiles="CC(C)(C)C") + assert ( + len( + [ + True + for (i, j) in parser.bonds + if parser.atoms[i].label == parser.atoms[j].label == "C" + ] + ) + == 4 + ) + builder.set_atoms_bonds(atoms=parser.atoms, bonds=parser.bonds) + assert builder.n_atoms == 17 + + +def test_d8(): + d8_smiles_list = [ + "[PH3]=[Pd](Cl)(Cl)=[PH3]", + "[PH3+][Pd-2](Cl)([PH3+])Cl", + # TODO Add some more test cases here + ] + + for smiles in d8_smiles_list: + parser.parse(smiles) + builder.set_atoms_bonds(atoms=parser.atoms, bonds=parser.bonds) + + pd_idx = next( + idx for idx, atom in enumerate(builder.atoms) if atom.label == "Pd" + ) + assert builder._atom_is_d8(idx=pd_idx) + + +def test_angle(): + water = Molecule(smiles="O") + angle = SAngle(idxs=[1, 0, 2]) # H, O, H + + assert angle.phi_ideal is None + assert np.isclose(angle.phi0, np.deg2rad(100), atol=10) + assert np.isclose( + angle.value(atoms=water.atoms), np.deg2rad(105), atol=np.deg2rad(15) + ) # ±15 degrees, a pretty loose tol + + +def test_dihedrals(): + trans = [ + Atom("C", -0.94807, -1.38247, -0.02522), + Atom("C", 0.54343, -1.02958, -0.02291), + Atom("C", -1.81126, -0.12418, -0.02130), + Atom("C", 1.40662, -2.28788, -0.02401), + ] + dihedral = SDihedral(idxs=[2, 0, 1, 3]) + assert np.isclose(dihedral.value(trans), np.pi, atol=0.05) + + gauche = [ + Atom("C", 0.33245, -2.84500, 0.36258), + Atom("C", 1.20438, -1.58016, 0.31797), + Atom("C", 0.85514, -3.97306, -0.52713), + Atom("C", 2.61201, -1.79454, 0.87465), + ] + + assert np.isclose(dihedral.value(gauche), np.deg2rad(-64.5), atol=0.01) + + zero = [ + Atom("C", 0.0, 0.0, 0.0), + Atom("C", 0.0, 0.0, 0.0), + Atom("C", 0.0, 0.0, 0.0), + Atom("C", 2.61201, -1.79454, 0.87465), + ] + + # Can't have a dihedral with vectors of zero length + with pytest.raises(Exception): + _ = dihedral.value(zero) + + +def test_cdihedral_rotation(): + try: + # from ade_dihedrals import rotate + from ade_dihedrals import rotate + + except ModuleNotFoundError: + return + + # If the extension is found then ensure that the dihedral rotation works + parser.parse(smiles="CC") + builder.build(parser.atoms, parser.bonds) + mol = Molecule(atoms=builder.atoms) + + dihedral = SDihedral(idxs=[2, 0, 1, 5]) + coords = mol.coordinates + + rot_idxs = np.zeros(shape=(1, mol.n_atoms), dtype="i4") + rot_idxs[0, [5, 6, 7]] = 1 + + rot_coords = rotate( + py_coords=np.array(coords, dtype="f8"), + py_angles=np.array([-dihedral.value(builder.atoms)], dtype="f8"), + py_axes=np.array([[1, 0]], dtype="i4"), + py_rot_idxs=rot_idxs, + py_origins=np.array([0], dtype="i4"), + ) + + mol.coordinates = rot_coords + + assert np.isclose(dihedral.value(mol.atoms), 0.0, atol=1e-5) + assert are_coords_reasonable(mol.coordinates) + + # Minimising on this dihedral should rotate it from 0º, however as + # 0.0 is a saddle point there needs to be a slight displacement, and + # should minimise to the next minimum at a 60º dihedral + rot_coords = rotate( + py_coords=np.array(mol.coordinates, dtype="f8"), + py_angles=np.array([0.1], dtype="f8"), + py_axes=np.array([[1, 0]], dtype="i4"), + py_rot_idxs=rot_idxs, + py_origins=np.array([0], dtype="i4"), + minimise=True, + ) + + mol.coordinates = rot_coords + + assert np.isclose(dihedral.value(mol.atoms), np.deg2rad(60), atol=0.1) + assert are_coords_reasonable(mol.coordinates) + + +def test_simple_alkane(): + """A few simple linear and branched alkanes""" + + simple_smiles = ["C", "CC", "CCC", "CCCC", "CC(C)C"] + + for smiles in simple_smiles: + assert built_molecule_is_reasonable(smiles) + + +def test_long_alkane(): + """Should be able to build a long alkane without overlapping atoms""" + + assert built_molecule_is_reasonable(smiles="CCCCCCC") + + +def test_simple_multispecies(): + """Some simple molecules""" + + assert built_molecule_is_reasonable(smiles="O") # water + assert built_molecule_is_reasonable(smiles="N") # ammonia + assert built_molecule_is_reasonable(smiles="B") # BH3 + + +def test_simple_multispecies2(): + """A small set of molecules with more than just carbon atoms""" + + assert built_molecule_is_reasonable(smiles="N#N") + assert built_molecule_is_reasonable(smiles="OO") + assert built_molecule_is_reasonable(smiles="O=[N]=O") + assert built_molecule_is_reasonable(smiles="CN=C=O") + + +def test_simple_ring(): + """Small unsubstituted rings""" + + parser.parse(smiles="C1CCCCC1") # cyclohexane + builder.set_atoms_bonds(parser.atoms, parser.bonds) + ring_dihedrals = list(builder._ring_dihedrals(ring_bond=[3, 4])) + assert len(ring_dihedrals) == 3 + + assert builder.max_ring_n == 6 + + assert built_molecule_is_reasonable(smiles="C1CCCC1") # cyclopentane + assert built_molecule_is_reasonable(smiles="C1CCCCC1") # cyclohexane + assert built_molecule_is_reasonable(smiles="C1CCCCCC1") # cycloheptane + assert built_molecule_is_usually_reasonable( + smiles="C1CCCCCCC1" + ) # cycloctane + + +def test_double_bonds(): + assert built_molecule_is_reasonable(smiles="C=C") + assert built_molecule_is_reasonable(smiles="CC/C=C/CCC") + + # Trans + parser.parse(smiles="C/C=C/C") + builder.build(parser.atoms, parser.bonds) + + dihedral = SDihedral(idxs=[0, 1, 2, 3]) + value = np.abs(dihedral.value(builder.atoms)) + + assert np.isclose(value, -np.pi, atol=1e-4) or np.isclose( + value, np.pi, atol=1e-4 + ) + + # Cis double bond + for cis_smiles in (r"C/C=C\C", r"C\C=C/C"): + parser.parse(cis_smiles) + builder.build(parser.atoms, parser.bonds) + + value = np.abs(dihedral.value(builder.atoms)) + + assert np.isclose(value, 0.0, atol=1e-4) + + +def test_chiral_tetrahedral(): + """Check simple chiral carbons""" + + parser.parse(smiles="C[C@@H](Cl)F") + builder.build(parser.atoms, parser.bonds) + r_mol = Molecule(atoms=builder.atoms, name="R_chiral") + # r_mol.print_xyz_file() + + coords = r_mol.coordinates + + v1 = coords[0] - coords[1] # C-C + v1 /= np.linalg.norm(v1) + + v2 = coords[3] - coords[1] # C-F + v2 /= np.linalg.norm(v2) + + v3 = coords[2] - coords[1] # C-Cl + # C-Cl vector should be pointing out of the plane, with a positive + # component along the normal to the C-C-F plane + assert np.dot(v3, np.cross(v1, v2)) > 0 + + parser.parse(smiles="C[C@H](Cl)F") + builder.build(parser.atoms, parser.bonds) + s_mol = Molecule(atoms=builder.atoms, name="S_chiral") + # s_mol.print_xyz_file() + + # Different chirality should have RMSD > 0.1 Å on heavy atoms + assert calc_heavy_atom_rmsd(s_mol.atoms, r_mol.atoms) > 0.1 + + +def test_chiral_tetrahedral2(): + s_smiles = "F[C@]([H])(C)Cl" + parser.parse(s_smiles) + builder.build(parser.atoms, parser.bonds) + s_mol = Molecule(atoms=builder.atoms) + + parser.parse(smiles="F[C@H](C)Cl") + builder.build(parser.atoms, parser.bonds) + s_mol = Molecule(atoms=builder.atoms) + + # Two representations should be the same + assert calc_heavy_atom_rmsd(s_mol.atoms, builder.atoms) < 0.1 + + +def test_chiral_tetrahedral3(): + """Equivalent SMILES strings from http://opensmiles.org/opensmiles.html""" + + parser.parse(smiles="N[C@](Br)(O)C") + builder.build(parser.atoms, parser.bonds) + mol = Molecule(atoms=builder.atoms) + + equiv_smiles = [ + "N[C@](Br)(O)C", + "Br[C@](O)(N)C", + "O[C@](Br)(C)N", + "Br[C@](C)(O)N", + "C[C@](Br)(N)O", + "Br[C@](N)(C)O", + "C[C@@](Br)(O)N", + "Br[C@@](N)(O)C", + "[C@@](C)(Br)(O)N", + "[C@@](Br)(N)(O)C", + ] + + for smiles in equiv_smiles: + parser.parse(smiles) + builder.build(parser.atoms, parser.bonds) + equiv_mol = Molecule(atoms=builder.atoms) + + # Atoms may not be in the same order, so map them + mapping = get_mapping(equiv_mol.graph, mol.graph) + atoms = [equiv_mol.atoms[i] for i in sorted(mapping, key=mapping.get)] + + assert calc_heavy_atom_rmsd(mol.atoms, atoms) < 0.2 + + +def test_sq_planar_xe(): + parser.parse(smiles="F[Xe](F)(F)F") + builder.build(atoms=parser.atoms, bonds=parser.bonds) + coords = builder.coordinates + + assert builder.atoms[1].label == "Xe" + + normal = np.cross((coords[0] - coords[1]), (coords[2] - coords[1])) + + for f_atom_idx in range(2, 4): + dot_product = np.dot((coords[f_atom_idx] - coords[1]), normal) + + # Dot product should be close to zero as the Xe-F vectors should be + # roughly orthogonal to the normal out of the plane, if the geometry + # is square planar + assert np.isclose(dot_product, 0.0, atol=0.1) + + +def test_macrocycle(): + # Large linear structure with stereochemistry + lin_smiles = ( + "C/C=C/[C@@H](C)[C@H](O[Si](C)(C)C)[C@@H](OC)/C=C" "/CC/C=C/C(OC)=O" + ) + assert built_molecule_is_reasonable(smiles=lin_smiles) + + # Large macrocyclic ring with stereochemistry + macro_smiles = ( + "C/C1=C/[C@@H](C)[C@H](O[Si](C)(C)C)[C@@H](OC)/C=C" "/CC/C=C/C(OC1)=O" + ) + assert built_molecule_is_usually_reasonable(smiles=macro_smiles) + + +def test_branches_on_rings(): + """Branches on rings should be fine""" + + assert built_molecule_is_reasonable(smiles="C1CC(CCC)C(CC)CC1") + assert built_molecule_is_reasonable(smiles="C1NC(CNC)C(CO)CC1") + assert built_molecule_is_reasonable(smiles="C1C(C)C(C)C(C)C(C)C1") + + +def test_aromatics(): + assert built_molecule_is_reasonable(smiles="C1=CC=CC=C1") # benzene + assert built_molecule_is_reasonable(smiles="c1ccccc1") # benzene + + +def test_small_rings(): + """Small rings may need angle adjustment to be reasonable""" + + parser.parse(smiles="C1CC1") + builder.build(parser.atoms, parser.bonds) + mol = Molecule(atoms=builder.atoms) + assert 1.3 < mol.distance(1, 2) < 1.7 # Closing CC bond should be close + + parser.parse(smiles="C1CCC1") + builder.build(parser.atoms, parser.bonds) + mol = Molecule(atoms=builder.atoms) + assert 1.3 < mol.distance(2, 3) < 1.7 # Closing CC bond should be close + + +def test_wikipedia_examples(): + """From: wikipedia.org/wiki/Simplified_molecular-input_ine-entry_system""" + + smiles_list = [ + "O=Cc1ccc(O)c(OC)c1", + "COc1cc(C=O)ccc1O", + "CC(=O)NCCC1=CNc2c1cc(OC)cc2", + ] + + for smiles in smiles_list: + assert built_molecule_is_reasonable(smiles=smiles) + + +def test_metal_complexes(): + # Check some simple complexes with CN > 4 [Co(Cl)5]2- + assert built_molecule_is_reasonable(smiles="Cl[Co-2](Cl)(Cl)(Cl)Cl") + # [Co(Cl)6]3- + assert built_molecule_is_reasonable(smiles="Cl[Co-3](Cl)(Cl)(Cl)(Cl)Cl") + + # Some higher coordinations (unphysical) + assert built_molecule_is_reasonable(smiles="F[Co](F)(F)(F)(F)(F)F") + assert built_molecule_is_reasonable(smiles="F[Co](F)(F)(F)(F)(F)(F)F") + + +def test_fused_rings(): + """Test building fused rings, for which dihedral adjustment + is not sufficent""" + + # Fused cyclobutane/cyclopentane + parser.parse(smiles="C1CC2C(C1)CC2") + builder.build(parser.atoms, parser.bonds) + mol = Molecule(atoms=builder.atoms) + # mol.print_xyz_file(filename='tmp.xyz') + + assert are_coords_reasonable(mol.coordinates) + assert all( + 1.3 < mol.distance(*pair) < 1.7 + for pair in mol.graph.edges + if mol.atoms[pair[0]].label == "C" and mol.atoms[pair[1]].label == "C" + ) + + +def test_trans_small_rings(): + """Rings with trans double bonds need to be possible""" + + parser.parse(smiles="C1CCC/C=C/CC1") + builder.build(parser.atoms, parser.bonds) + assert are_coords_reasonable(builder.coordinates) + + dihedral = SDihedral(idxs=[3, 4, 5, 6]) + # Should be anything with no defined stereochem + parser.parse(smiles="C1CCCC=CCC1") + builder.build(parser.atoms, parser.bonds) + assert -np.pi < dihedral.value(builder.atoms) < np.pi + + # or defined as cis + parser.parse(smiles=r"C1CCC/C=C\CC1") + builder.build(parser.atoms, parser.bonds) + assert -np.pi / 2.0 < dihedral.value(builder.atoms) < np.pi / 2.0 + + # but should be close to π if defined as trans + parser.parse(smiles="C1CCC/C=C/CC1") + builder.build(parser.atoms, parser.bonds) + assert np.isclose( + np.abs(dihedral.value(builder.atoms)), np.pi, atol=np.pi / 2.0 + ) + + +def test_dihedral_force(): + parser.parse(smiles="CCCC") + builder.build(atoms=parser.atoms, bonds=parser.bonds) + + dihedral = SDihedral(idxs=[0, 1, 2, 3], phi0=np.pi / 2.0) + + # Can only force to 0º or 180º, distances are hard coded + with pytest.raises(ValueError): + builder._force_double_bond_stereochem(dihedral=dihedral) + + +def test_close_flat_ring(): + unclosed_coords = np.array( + [ + [2.227521, -0.038228, -2.175656], + [-1.704563, 1.083528, 1.226912], + [-1.030701, 0.949700, 0.029400], + [0.303700, 1.342600, -0.014200], + [1.298500, 0.393400, -0.042500], + [1.462636, -0.467365, -1.114587], + [2.347920, -0.711203, -2.998888], + [-2.742329, 0.782273, 1.276031], + [-1.527315, 0.551663, -0.845852], + [0.505600, 2.411600, -0.024200], + [1.971412, 0.321051, 0.801897], + [0.984661, -1.435514, -1.071969], + ] + ) + + # populate everything by parsing normally + parser.parse("c1ccccc1") + builder.build(atoms=parser.atoms, bonds=parser.bonds) + + # Set the coordinates as something that is nor already closed + builder.coordinates = unclosed_coords + + bond = RingBond(0, symbol="-") # 0-1 atom indexes define the closing bond + bond.close(idx=1, symbol="-") + bond.r0 = np.linalg.norm(unclosed_coords[3] - unclosed_coords[2]) + + # re-apply the closure + builder._close_ring(ring_bond=bond) + mol = Molecule(atoms=builder.atoms) + + for atom_i in range(0, 5): + # All C-C distances should be ~1.5 Å in a benzene ring + assert np.isclose(mol.distance(atom_i, atom_i + 1), 1.5, atol=0.2) + + +def test_close_non_flat_ring(): + parser.parse("C1CCCCCC1") + builder.build(atoms=parser.atoms, bonds=parser.bonds) + mol = Molecule(atoms=builder.atoms) + + assert are_coords_reasonable(mol.coordinates) + + for atom_i in range(0, 5): + # All C-C distances should be ~1.5 Å in a cyclohexane ring + assert np.isclose(mol.distance(atom_i, atom_i + 1), 1.5, atol=0.2) + + +def test_double_bond_stereo_branch(): + """Check that a double bond stereochemisty is well defined over + explicit hydrogens""" + + parser.parse(smiles=r"C/C([H])=C([H])/C") + builder.build(atoms=parser.atoms, bonds=parser.bonds) + + # Carbon dihedral should be ~π for this trans double bond, as hydrogens + # don't count + dihedral = SDihedral( + idxs=[i for i, atom in enumerate(builder.atoms) if atom.label == "C"] + ) + assert np.isclose(np.abs(dihedral.value(builder.atoms)), np.pi, atol=0.1) + + +def test_fused_ring_system(): + """Multiply fused rings should be buildable - repulsion needed""" + + assert built_molecule_is_reasonable( + smiles="[SiH3]C12[C@@]3(CCC4)C4=C" "[C@@H](C1C=CC2)C3" + ) + + +def test_build_exceptions(): + builder.set_atoms_bonds(atoms=[SMILESAtom("H")], bonds=SMILESBonds()) + + # Cannot find a ring with no atoms + with pytest.raises(SMILESBuildFailed): + builder._ring_idxs(inc_idxs=(0, 1)) + + # Cannot find a ring path with no rings + bond = RingBond(1, symbol="-") + bond.close(1, symbol="-") + with pytest.raises(SMILESBuildFailed): + builder._ring_path(ring_bond=bond) + + +def test_ff_dist_matrix(): + parser.parse(smiles="C=O") + builder_ = Builder() + builder_.build(atoms=parser.atoms, bonds=parser.bonds) + + # Should not try and add any distance constraints across the double + # bond with no neighbours on oxygen + dist_matrix = builder_._ff_distance_matrix() + assert dist_matrix.shape == (builder_.n_atoms, builder_.n_atoms) + + +def test_difficult_reset_onto(): + points = np.array( + [ + [5.32654, 0.20363, -1.54392], + [6.25674, 0.01882, 0.69857], + [7.37946, 1.18199, -1.08374], + ] + ) + + coord = np.array([6.54572, 0.03685, -0.65653]) + + atom = TetrahedralAtom() + atom.reset_onto(points=points, coord=coord) + + mol = Molecule( + atoms=[Atom("C"), Atom("O"), Atom("C"), Atom("H"), Atom("C")] + ) + mol.coordinates = np.array( + points.tolist() + [atom.empty_site() + coord] + [coord.tolist()] + ) + + assert mol.angle(1, 4, 3).to("deg") > 90 + + +def test_max_ring_size(): + parser.parse( + smiles="O=C1C2=C(O[Si](C)(C)C)C[C@H](CCCC3)C3=C4C2CC[C@@H]4O1" + ) + builder.set_atoms_bonds(atoms=parser.atoms, bonds=parser.bonds) + assert builder.max_ring_n == 7 + + +def test_cis_dihedral_force(): + """Test the forcing of a cis-dihedral from a trans geometry""" + + parser.parse(smiles="CC=CC") + builder.build(parser.atoms, parser.bonds) + # pre-generated trans geometry + coords = [ + [-0.86310, -0.72859, 0.62457], + [0.10928, -0.05429, 1.42368], + [1.17035, -0.79134, 2.03167], + [2.14109, -0.11396, 2.83018], + [-0.46878, -1.52095, 0.23716], + [-1.16448, -0.14182, -0.08133], + [-1.61598, -0.98052, 1.17515], + [0.04809, 0.90129, 1.55228], + [1.23123, -1.74691, 1.90286], + [2.16958, -0.51498, 3.70870], + [3.00946, -0.19036, 2.41364], + [1.90166, 0.81880, 2.90791], + ] + + for atom, new_coord in zip(builder.atoms, coords): + atom.coord = new_coord + + builder._force_double_bond_stereochem( + dihedral=SDihedral([0, 1, 2, 3], phi0=0.0) + ) + + # Distance between the end carbons needs to be smaller than the trans + assert 2.0 < builder.distance(0, 3) < 3.5 + + mol = Molecule() + init_smiles(mol, smiles=r"C/C=C\C") + assert -20 < mol.dihedral(0, 1, 2, 3).to("deg") < 20 + + +def test_many_ring_double_bonds(): + assert built_molecule_is_reasonable(smiles=r"C1=C\N=C/C=N\C=C/C/1") + assert built_molecule_is_reasonable(smiles=r"C1=CC=C/N=N\C=C1") diff --git a/autodE/source/tests/test_smiles_parser.py b/autodE/source/tests/test_smiles_parser.py new file mode 100644 index 0000000000000000000000000000000000000000..45443a428b0a06dcd27b50479092312fcd1a4c68 --- /dev/null +++ b/autodE/source/tests/test_smiles_parser.py @@ -0,0 +1,505 @@ +import re +import pytest +from copy import deepcopy +from autode.atoms import metals +from autode.exceptions import InvalidSmilesString +from autode.smiles.base import SMILESStereoChem +from autode.smiles.parser import Parser + + +def test_base_properties(): + parser = Parser() + + assert parser.mult == 1 + assert parser.n_atoms == 0 + assert parser.charge == 0 + + with pytest.raises(InvalidSmilesString): + parser.smiles = "C*C" + + # Should allow for SMILES typos with leading or final empty spaces + parser.parse(smiles="C ") + + # parser treats hydrogens as attributes of atoms + assert parser.n_atoms == 1 + assert parser.atoms[0].n_hydrogens == 4 + + assert str(parser.atoms[0]) is not None + + +def test_sq_brackets_parser(): + parser = Parser() + with pytest.raises(InvalidSmilesString): + parser.parse(smiles="[C") + + with pytest.raises(InvalidSmilesString): + parser.parse(smiles="[C[") + + # Needs at least one element + with pytest.raises(InvalidSmilesString): + parser.parse(smiles="[]") + + parser.parse(smiles="[C]") + assert parser.n_atoms == 1 + assert parser.atoms[0].label == "C" + assert parser.parsed # should have parsed the SMILES fully + + parser.parse(smiles="[Cu]") + assert parser.n_atoms == 1 + assert parser.atoms[0].label == "Cu" + assert parser.atoms[0].charge == 0 + + # Item in a square bracket must start with an element + with pytest.raises(InvalidSmilesString): + parser.parse(smiles="[674]") + + # Can't have multiple heavy (non-hydrogenic atoms) in a square bracket + with pytest.raises(InvalidSmilesString): + parser.parse(smiles="[CC]") + + parser.parse(smiles="[CH3-]") + assert parser.atoms[0].charge == -1 + assert parser.atoms[0].n_hydrogens == 3 + + parser.parse(smiles="[Cu+]") + assert parser.atoms[0].charge == 1 + + parser.parse(smiles="[N+4]") + assert parser.atoms[0].charge == 4 + assert parser.charge == 4 + + parser.parse(smiles="[Cu++]") + assert parser.atoms[0].charge == 2 + + parser.parse(smiles="[N--]") + assert parser.atoms[0].charge == -2 + + parser.parse(smiles="[OH-1]") + assert parser.atoms[0].charge == -1 + + parser.parse(smiles="[NH-]") + assert parser.atoms[0].charge == -1 + + parser.parse(smiles="[N-2]") + assert parser.atoms[0].charge == -2 + + parser.parse(smiles="[Si@H3-]") + assert parser.atoms[0].stereochem == SMILESStereoChem.TET_NORMAL + assert parser.atoms[0].charge == -1 + assert parser.atoms[0].n_hydrogens == 3 + + parser.parse(smiles="[C@@H2-]") + assert parser.atoms[0].has_stereochem + assert parser.atoms[0].stereochem == SMILESStereoChem.TET_INVERTED + assert parser.atoms[0].charge == -1 + assert parser.atoms[0].n_hydrogens == 2 + assert parser.charge == -1 + + +def test_multiple_atoms(): + parser = Parser() + parser.parse(smiles="CC") + assert parser.n_atoms == 2 + assert str(parser.bonds[0]) is not None + assert parser.bonds[0].symbol == "-" + + assert all(atom.label == "C" for atom in parser.atoms) + assert all(atom.charge == 0 for atom in parser.atoms) + + assert len(parser.bonds) == 1 + assert parser.bonds[0].order == 1 + + parser.parse(smiles="[H][H]") + assert parser.n_atoms == 2 + assert len(parser.bonds) == 1 + + parser.parse(smiles="CN") + assert parser.n_atoms == 2 + assert parser.bonds[0].order == 1 + + parser.parse(smiles="N#N") + assert parser.n_atoms == 2 + assert parser.bonds[0].order == 3 + + parser.parse(smiles="C=O") + assert parser.n_atoms == 2 + assert parser.bonds[0].order == 2 + + parser.parse(smiles="CN=C=O") + assert parser.n_atoms == 4 + assert len(parser.bonds) == 3 + + +def test_branches(): + # Propane, but with a branch from the first atom + parser = Parser() + parser.parse(smiles="C(C)C") + assert parser.n_atoms == 3 + assert parser.n_bonds == 2 + + b1, b2 = parser.bonds + assert (b1[0] == 0 and b1[1] == 1) or (b1[0] == 1 and b1[1] == 0) + assert (b2[0] == 0 and b2[1] == 2) or (b2[0] == 2 and b2[1] == 0) + + # isobutane - properly branched + parser.parse(smiles="CC(C)C") + assert parser.n_atoms == 4 + assert parser.n_bonds == 3 + + # octachlorodirhenate + parser.parse(smiles="[Rh-](Cl)(Cl)(Cl)(Cl)$[Rh-](Cl)(Cl)(Cl)Cl") + assert parser.n_atoms == 10 + assert parser.n_bonds == 9 + + # should have a single quadruple bond + assert any(bond.order == 4 for bond in parser.bonds) + + # 2-propyl-3-isopropyl-1-propanol + parser.parse(smiles="OCC(CCC)C(C(C)C)CCC") + assert parser.n_atoms == 13 + assert parser.n_bonds == 12 + + # thiosulfate + parser.parse(smiles="OS(=O)(=S)O") + assert parser.n_atoms == 5 + assert parser.n_bonds == 4 + + +def test_rings(): + parser = Parser() + + # cyclohexane + parser.parse(smiles="C1CCCCC1") + assert parser.n_atoms == parser.n_bonds == 6 + + with pytest.raises(InvalidSmilesString): + parser.parse(smiles="C1CCCCC") + + # Should be able to resolve multiple cyclohexenes to the same structure + def n_double_bonds(): + return len([bond for bond in parser.bonds if bond.order == 2]) + + cychexene_smiles = ["C=1CCCCC=1", "C=1CCCCC1", "C1CCCCC=1 "] + for smiles in cychexene_smiles: + parser.parse(smiles) + assert parser.n_atoms == parser.n_bonds == 6 + assert n_double_bonds() == 1 + + # perhydroisoquinoline + parser.parse(smiles="N1CC2CCCC2CC1") + assert parser.n_bonds == 10 + assert parser.n_atoms == 9 + + # has 2 bonds that close rings, but plenty of bonds that form rings + ring_bonds = [bond for bond in parser.bonds if bond.closes_ring] + assert len(ring_bonds) == 2 + + # Reusing ring closures is fine.. + bicylcohexyl_smiles = ["C1CCCCC1C2CCCCC2", "C1CCCCC1C1CCCCC1"] + for smiles in bicylcohexyl_smiles: + parser.parse(smiles) + assert parser.n_atoms == 12 + assert parser.n_bonds == 13 + + # Should be able to parse atoms with multiple dangling bonds to the + # same atom + parser.parse(smiles="C12(CCCCC1)CCCCC2") + assert parser.n_atoms == 11 + + # Should correct for atoms bonded to themselves + parser.parse(smiles="C11") + assert parser.n_atoms == 1 + assert parser.n_bonds == 0 + + +def test_aromatic(): + parser = Parser() + parser.parse(smiles="c1occc1") + assert parser.n_atoms == 5 + assert parser.n_bonds == 5 + + +def test_hydrogens(): + parser = Parser() + + # H atoms defined explicitly are treated as atoms + parser.parse(smiles="[H]C([H])([H])[H]") + assert parser.n_atoms == 5 + assert parser.n_bonds == 4 + + assert len(parser.atoms) == 5 + + +def test_cis_trans(): + parser = Parser() + + # Check that without defined stereochem the C-C double bond is present + parser.parse(smiles="C(F)=CF") + double_bond = next(bond for bond in parser.bonds if bond.order == 2) + idx_i, idx_j = double_bond + assert parser.atoms[idx_i].label == "C" + assert parser.atoms[idx_j].label == "C" + + # trans (E) diflorouethene + trans_dfe_smiles = ["F/C=C/F", r"F\C=C\F", r"C(\F)=C/F"] + + for smiles in trans_dfe_smiles: + parser.parse(smiles) + + double_bond = next(bond for bond in parser.bonds if bond.order == 2) + assert double_bond.is_trans(atoms=parser.atoms) + assert not double_bond.is_cis(atoms=parser.atoms) + + # test the cis equivalent + cis_dfe_smiles = [r"F\C=C/F", r"F\C=C/F", "C(/F)=C/F"] + + for smiles in cis_dfe_smiles: + parser.parse(smiles) + double_bond = next(bond for bond in parser.bonds if bond.order == 2) + assert double_bond.is_cis(atoms=parser.atoms) + + parser.parse(smiles="F/C(CC)=C/F") + double_bonds = [bond for bond in parser.bonds if bond.order == 2] + assert len(double_bonds) == 1 + assert double_bonds[0].is_trans(atoms=parser.atoms) + + # Test allene stereochem + parser.parse(smiles=r"F/C=C=C=C/F") + # First carbon should be assigned stereochemistry + assert parser.atoms[1].label == "C" + assert parser.atoms[1].has_stereochem + + +def test_is_pi_atom(): + parser = Parser() + + parser.parse(smiles="C1=CC=CC=C1") # benzene + assert all(atom.is_pi for atom in parser.atoms) + + parser.parse(smiles="c1ccccc1") # benzene, but with aromatic atoms + assert all(atom.is_pi for atom in parser.atoms) + + +def test_implicit_hydrogens(): + parser = Parser() + parser.parse(smiles="CC") + # ethane carbons should have three hydrogens each + assert parser.atoms[0].n_hydrogens == parser.atoms[1].n_hydrogens == 3 + + parser.parse(smiles="B") + assert parser.atoms[0].n_hydrogens == 3 + + parser.parse(smiles="BC") + assert parser.atoms[0].n_hydrogens == 2 + + parser.parse(smiles="CBC") + assert parser.atoms[1].n_hydrogens == 1 + + parser.parse(smiles="P") + assert parser.atoms[0].n_hydrogens == 3 + + # For PF3 no hydrogens should be added + parser.parse(smiles="FP(F)F") + assert parser.atoms[1].n_hydrogens == 0 + + # Should fill the valance of P up to 5 if currently is 4 + parser.parse(smiles="FP(F)(F)F") + assert parser.bonds.n_involving(idx=1) == 4 + assert parser.atoms[1].n_hydrogens == 1 + + # Should fill the valance of S up to 6 if currently is 5 + parser.parse(smiles="FS(F)(F)(F)F") + assert parser.bonds.n_involving(idx=1) == 5 + assert parser.atoms[1].n_hydrogens == 1 + + for halogen in ("F", "Cl", "Br", "I"): + parser.parse(smiles=f"C{halogen}") + assert parser.atoms[0].n_hydrogens == 3 + assert parser.atoms[1].n_hydrogens == 0 + + # Should fill up to HCl etc. + parser.parse(smiles="Cl") + assert parser.n_atoms == 1 + assert parser.atoms[0].n_hydrogens == 1 + + # Should not overfill an oxygen valance that is already exceeded + parser.parse(smiles="CO(C)O") + assert parser.atoms[1].n_hydrogens == 0 + + parser.parse(smiles="O=[N]=O") + assert parser.n_bonds == 2 + assert parser.bonds[0].order == parser.bonds[1].order == 2 + assert parser.atoms[1].n_hydrogens == 0 + + # Should be able to parse aromatic structures + parser.parse(smiles="c1ccccc1") + assert all(atom.n_hydrogens == 1 for atom in parser.atoms) + + parser.parse(smiles="c1occc1") + assert all( + atom.n_hydrogens == 0 for atom in parser.atoms if atom.label == "O" + ) + assert all( + atom.n_hydrogens == 1 for atom in parser.atoms if atom.label == "C" + ) + + +def test_multiplicity(): + parser = Parser() + + # Test some simple examples + parser.parse(smiles="[H]") + assert parser.mult == 2 + + parser.parse(smiles="C") + assert parser.mult == 1 + + # Multiple unpaired electrons default to singlets.. + parser.parse(smiles="C[C]C") + assert parser.mult == 1 + + +def test_double_bond_stereo_branch(): + parser = Parser() + parser.parse(smiles=r"C/C([H])=C([H])/C") + + assert next(bond for bond in parser.bonds if bond.order == 2).is_trans( + parser.atoms + ) + + +def test_alt_ring_branch(): + parser = Parser() + smiles = ( + "O=C=[Rh]12(CC2)([H])=P(C3=CC=CC=C3)(C4=CC=CC=C4)C(C=CC=C5C6" + "(C)C)=C5OC7=C6C=CC=C7P=1(C8=CC=CC=C8)C9=CC=CC=C9" + ) + + parser.parse(smiles) + num_h_atoms = sum(atom.n_hydrogens for atom in parser.atoms) + + assert parser.n_atoms + num_h_atoms == 84 + + +def test_ring_connectivity(): + parser = Parser() + # Structure has a C-S(O2)-C motif + parser.parse("CC12[C@@]3(CCC4)C4=C[C@@H](C1C=CO2)S(=O)3=O") + + atom_symbols_in_bonds = [ + {parser.atoms[i].label, parser.atoms[j].label} for i, j in parser.bonds + ] + + n_c_s_bonds = len( + [pair for pair in atom_symbols_in_bonds if pair == {"C", "S"}] + ) + + # and has two carbon-sulfur bonds + assert n_c_s_bonds == 2 + + +def test_multiplicity_metals(): + parser = Parser() + + parser.parse(smiles="[Na]C1=CC=CC=C1") + assert parser.mult == 1 + + +def test_aromatic_heteroatoms(): + parser = Parser() + parser.parse(smiles="[nH]1cnnc1") + + # Should have 1 atom per carbon, plus one for the defined aromatic N + assert sum(atom.n_hydrogens for atom in parser.atoms) == 3 + + # also should not have any Hs for aromatic B + parser.parse(smiles="c1c[cH-]bc1") + assert sum(atom.n_hydrogens for atom in parser.atoms) == 4 + + +def test_metal_in_smiles(): + def metal_in_smiles(smiles): + at_strings = re.findall(r"\[.*?]", smiles) + return any( + metal in string for metal in metals for string in at_strings + ) + + assert not metal_in_smiles(smiles="CnnC") + assert metal_in_smiles(smiles="CC[W]") + assert metal_in_smiles(smiles="C[Pd]") + assert metal_in_smiles(smiles="[Fe3+]CNO[W]") + + +def test_lots_of_smiles_rings(): + parser = Parser() + + # Should be able to parse a SMILES with ring closures with multiple + # digits + parser.parse(smiles="C%99CCCC%99") + cyclopentane_atoms = deepcopy(parser.atoms) + + parser.parse(smiles="C1CCCC1") + assert all( + parser.atoms[i].label == cyclopentane_atoms[i].label + for i in range(len(parser.atoms)) + ) + + +def is_invalid(smiles): + with pytest.raises(InvalidSmilesString): + Parser().parse(smiles) + + +def is_valid(smiles): + Parser().parse(smiles) # Throws if invalid + return True + + +def test_parse_ring_idx(): + # % ring closures must be followed by two numbers + is_invalid("C%9CC") + + # and have at least two characters following the % + is_invalid(smiles="C%") + + # and no non-integer characters + is_invalid(smiles="C%$$") + + # Check that the function does reasonable things even if there is no + # ring index present + parser = Parser() + + parser._string = "CCCC" + with pytest.raises(InvalidSmilesString): + parser._parse_ring_idx(idx=0) + + +def test_parse_smiles_with_labels_no_h(): + parser = Parser() + parser.parse("C[Br:777]") + + assert sum(["Br" == atom.atomic_symbol for atom in parser.atoms]) == 1 + + br_atom = next(a for a in parser.atoms if a.label == "Br") + assert br_atom.atom_class == 777 + + +def test_parse_smiles_with_labels_with_h(): + parser = Parser() + + parser.parse("[CH4:2]") + assert next(a for a in parser.atoms if a.label == "C").atom_class == 2 + + +def test_parse_h3o_cation_smiles(): + assert is_valid("[O+H2]") + + +def test_parse_smiles_atom_class(): + assert is_valid("[H:1]") + is_invalid("[H:1.1]") + is_invalid("[H:a]") + + +def test_multiple_bonds_in_ring(): + assert is_valid(r"C1C/C=C\C=C/CC/C=C\1") diff --git a/autodE/source/tests/test_sn2prime.py b/autodE/source/tests/test_sn2prime.py new file mode 100644 index 0000000000000000000000000000000000000000..c1631577be327f741793770ec8e34315a01e72fc --- /dev/null +++ b/autodE/source/tests/test_sn2prime.py @@ -0,0 +1,85 @@ +""" +Test that an SN2' substitution reaction is correctly generated +""" +from autode.reactions import Reaction +from autode.species import ReactantComplex +from autode.atoms import Atom +from autode.reactions.reaction_types import Substitution +from autode.species import Reactant, Product +from autode.bond_rearrangement import get_bond_rearrangs +from autode.bond_rearrangement import BondRearrangement +from autode.substitution import get_substc_and_add_dummy_atoms +from autode.input_output import xyz_file_to_atoms +from autode.transition_states.locate_tss import translate_rotate_reactant +from . import testutils +import os + +here = os.path.dirname(os.path.abspath(__file__)) + + +def test_detection(): + # F- + H2CCHCH2Cl -> FCH2CHCH2 + Cl- + reaction = Reaction( + Reactant(name="F-", charge=-1, atoms=[Atom("F")]), + Reactant(name="alkeneCl", smiles="C=CCCl"), + Product(name="alkeneF", smiles="C=CCF"), + Product(name="Cl-", charge=-1, atoms=[Atom("Cl")]), + ) + + assert reaction.type == Substitution + + reactant, product = reaction.reactant, reaction.product + + bond_rearrs = get_bond_rearrangs(reactant, product, name="SN2") + + # autodE should find both direct SN2 and SN2' pathways + assert len(bond_rearrs) == 2 + os.remove("SN2_BRs.txt") + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "sn2prime.zip")) +def test_subst(): + reactant = Reactant(name="sn2_r", atoms=xyz_file_to_atoms("reactant.xyz")) + + # SN2' bond rearrangement + bond_rearr = BondRearrangement( + forming_bonds=[(0, 1)], breaking_bonds=[(3, 4)] + ) + + subst_centers = get_substc_and_add_dummy_atoms( + reactant, bond_rearr, shift_factor=1.0 + ) + + assert len(subst_centers) == 1 + + # get_substitution_centres should add a dummy atom so the ACX angle is + # defined + assert len(reactant.atoms) == 11 + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "sn2prime.zip")) +def test_translate_rotate(): + reactant = ReactantComplex( + Reactant(name="F-", charge=-1, atoms=[Atom("F")]), + Reactant(name="alkeneCl", atoms=xyz_file_to_atoms("alkene.xyz")), + ) + + assert reactant.n_molecules == 2 + + # Initially the geometry is not sensible + assert reactant.distance(0, 2) < 1.0 + + # SN2' bond rearrangement + bond_rearr = BondRearrangement( + forming_bonds=[(0, 1)], breaking_bonds=[(3, 4)] + ) + + translate_rotate_reactant(reactant, bond_rearr, shift_factor=1.5) + assert len(reactant.atoms) == 10 + + # The geometry should now be sensible + for i in range(1, 10): + assert reactant.distance(0, i) > 2.0 + + # Should be closer to the end carbon than the middle + assert reactant.distance(0, 1) < reactant.distance(0, 2) diff --git a/autodE/source/tests/test_solvents.py b/autodE/source/tests/test_solvents.py new file mode 100644 index 0000000000000000000000000000000000000000..3205373ae31fc5c010602ca4abe8ecbcde62faad --- /dev/null +++ b/autodE/source/tests/test_solvents.py @@ -0,0 +1,74 @@ +import pytest +from autode.species import Molecule +from autode.solvent import solvents, get_solvent +from autode.wrappers.ORCA import orca +from autode.exceptions import SolventNotFound + + +def test_solvent(): + methane = Molecule(smiles="C") + methane.solvent = "water" + assert "water" in repr(methane.solvent).lower() + + # Calculation should be able to handle a solvent given as just a string + assert hasattr(methane.solvent, "mopac") + + # Unknown solvent should raise an exception + with pytest.raises(SolventNotFound): + methane.solvent = "XXXX" + + # Default to implicit solvation + assert methane.solvent.is_implicit + + +def test_avail_solvents(): + assert "water" in orca.available_implicit_solvents + + +def test_get_solvent(): + # Solvent must be implicit or explicit + with pytest.raises(ValueError): + _ = get_solvent(solvent_name="water", kind="x") + + water = get_solvent(solvent_name="water", kind="implicit") + assert water.name == "water" + assert water.smiles == "O" + assert "h2o" in water.aliases + assert "water" in water.aliases + assert water.dielectric is not None + + with pytest.raises(SolventNotFound): + _ = get_solvent(solvent_name="test_solvent", kind="implicit") + + assert water is not None + assert water == get_solvent(solvent_name="h2o", kind="implicit") + + # Must define the number of explicit solvent molecules to add + with pytest.raises(ValueError): + _ = get_solvent("water", kind="explicit") + + assert get_solvent("h2o", kind="implicit") != get_solvent( + "h2o", kind="explicit", num=10 + ) + + +def test_solvent_dielectric(): + water = solvents.get_solvent("water", kind="implicit") + assert abs(water.dielectric - 78) < 1 + + assert solvents.ImplicitSolvent("X", "X", aliases=["X"]).dielectric is None + + +def test_unavailable_methods_for_implicit_solvents(): + solvent = get_solvent("water", kind="implicit") + assert solvent.atoms is None + + # Can't randomise an implicit solvent around a solute + with pytest.raises(RuntimeError): + solvent.randomise_around(solute=Molecule("C")) + + +def test_unavailable_methods_for_explicit_solvents(): + solvent = get_solvent("water", kind="explicit", num=1) + with pytest.raises(RuntimeError): + solvent.to_explicit(num=2) # already explicit diff --git a/autodE/source/tests/test_species.py b/autodE/source/tests/test_species.py new file mode 100644 index 0000000000000000000000000000000000000000..c1e6df247729ff041c646a096427c2c6475da530 --- /dev/null +++ b/autodE/source/tests/test_species.py @@ -0,0 +1,702 @@ +from autode.species.species import Species +from autode.species.molecule import Molecule +from autode.wrappers.ORCA import orca +from autode.wrappers.XTB import xtb +from autode.calculations import Calculation +from autode.conformers import Conformers +from autode.atoms import Atom +from autode.solvent.solvents import Solvent +from autode.solvent.solvents import get_solvent +from autode.geom import calc_rmsd +from autode.values import Gradient, EnthalpyCont, PotentialEnergy +from autode.units import ha_per_ang +from autode.exceptions import NoAtomsInMolecule, CalculationException +from autode.utils import work_in_tmp_dir +from scipy.spatial import distance_matrix +from copy import deepcopy +from . import testutils +import numpy as np +import pytest +import os + +here = os.path.dirname(os.path.abspath(__file__)) + +h1 = Atom("H") +h2 = Atom("H", z=1.0) + +mol = Species(name="H2", atoms=[h1, h2], charge=0, mult=1) + + +def test_species_class(): + blank_mol = Species(name="tmp", atoms=None, charge=0, mult=1) + + assert blank_mol.n_atoms == 0 + assert blank_mol.n_conformers == 0 + assert blank_mol.radius == 0 + + assert str(blank_mol) != "" # Should have some string representations + assert repr(blank_mol) != "" + + assert blank_mol.has_reasonable_coordinates # No coordinates are good + + assert hasattr(mol, "print_xyz_file") + assert hasattr(mol, "translate") + assert hasattr(mol, "rotate") + assert hasattr(mol, "coordinates") + assert str(mol) != "" + + assert mol.charge == 0 + assert mol.mult == 1 + assert mol.name == "H2" + + for attr in ( + "gradient", + "hessian", + "free_energy", + "enthalpy", + "g_cont", + "h_cont", + "frequencies", + "vib_frequencies", + "imaginary_frequencies", + ): + assert getattr(mol, attr) is None + + assert mol.normal_mode(mode_number=1) is None + + assert not mol.is_explicitly_solvated + + # A not very sensible water geometry! + water = Species( + name="H2O", + charge=0, + mult=1, + atoms=[Atom("O"), Atom("H", z=-1), Atom("H", z=1)], + ) + + assert water.formula == "H2O" or water.formula == "OH2" + + # Species without a molecular graph (no atoms) cannot define a bond matrix + with pytest.raises(Exception): + _ = Molecule().bond_matrix + + # very approximate molecular radius + assert 0.5 < water.radius < 2.5 + + # Base class for molecules and TSs and complexes shouldn't have a + # implemented conformer method – needs to do different things based on the + # type of species to find conformers + with pytest.raises(NotImplementedError): + water.find_lowest_energy_conformer(lmethod=xtb) + + # Cannot optimise a molecule without a method or a calculation + with pytest.raises(ValueError): + water.optimise() + + +def test_species_energies_reset(): + tmp_species = Species(name="H2", atoms=[h1, h2], charge=0, mult=1) + tmp_species.energy = 1.0 + + assert len(tmp_species.energies) == 1 + + # At the same geometry other energies are retained, even if energy=None(?) + tmp_species.energy = None + assert len(tmp_species.energies) == 1 + + # Translating the molecule should leave the energy unchanged + tmp_species.atoms = [Atom("H", z=1.0), Atom("H", z=2.0)] + assert tmp_species.energy == 1.0 + + # and also rotating it + tmp_species.atoms = [Atom("H"), Atom("H", x=1.0)] + assert tmp_species.energy == 1.0 + + # but adjusting the distance should reset the energy + tmp_species.atoms = [Atom("H"), Atom("H", z=1.1)] + assert tmp_species.energy is None + + # changing the number of atoms should reset the energy + tmp_species.energy = 1.0 + tmp_species.atoms = [Atom("H")] + assert tmp_species.energy is None + + # likewise changing the atom number + tmp_species.atoms = [Atom("H"), Atom("H", x=1.0)] + tmp_species.energy = 1.0 + tmp_species.atoms = [Atom("H"), Atom("F", x=1.0)] + + assert tmp_species.energy is None + + +def test_connectivity(): + _h2 = Species(name="H2", atoms=[h1, h2], charge=0, mult=1) + _h2.reset_graph() + + # Must have the same connectivity as itself + assert _h2.has_same_connectivity_as(_h2) + + # Graphs are lazy loaded so if undefined one is built + _h2_no_set = _h2.copy() + _h2_no_set.graph = None + _h2.has_same_connectivity_as(_h2_no_set) + + # Or something without a graph attribute is passed + with pytest.raises(ValueError): + _h2.has_same_connectivity_as("a") + + # Different number of atoms have different connectivity + assert not _h2.has_same_connectivity_as(Molecule(atoms=None)) + + # No atom molecule have the same connectivity + assert Molecule(atoms=None).has_same_connectivity_as(Molecule(atoms=None)) + + +def test_species_xyz_file(): + mol.print_xyz_file() + assert os.path.exists("H2.xyz") + xyz_file_lines = open("H2.xyz", "r").readlines() + + # First item in the xyz file needs to be the number of atoms + assert int(xyz_file_lines[0].split()[0]) == 2 + + # Third line needs to be in the format H, x, y, z + assert len(xyz_file_lines[2].split()) == 4 + + os.remove("H2.xyz") + + mol_copy = mol.copy() + mol_copy.atoms = None + + with pytest.raises(NoAtomsInMolecule): + mol_copy.print_xyz_file() + + +def test_species_translate(): + m = Species( + name="H2", atoms=[Atom("H"), Atom("H", z=1.0)], charge=0, mult=1 + ) + m.translate(vec=np.array([0.0, 0.0, -1.0])) + + expected = np.array([[0.0, 0.0, -1.0], [0.0, 0.0, 0.0]]) + + assert np.allclose(m.atoms[0].coord, expected[0, :]) + assert np.allclose(m.atoms[1].coord, expected[1, :]) + assert np.allclose(m.coordinates, expected) + + # Centering should move the middle of the molecule to the origin + m.centre() + assert np.allclose( + np.average(m.coordinates, axis=0), np.zeros(3), atol=1e-4 + ) + + +def test_species_rotate(): + m = Species( + name="H2", atoms=[Atom("H"), Atom("H", z=1.0)], charge=0, mult=1 + ) + # Rotation about the y axis 180 degrees (π radians) + m.rotate(axis=np.array([1.0, 0.0, 0.0]), theta=np.pi) + + assert np.linalg.norm(m.atoms[0].coord - np.array([0.0, 0.0, 0.0])) < 1e-9 + assert np.linalg.norm(m.atoms[1].coord - np.array([0.0, 0.0, -1.0])) < 1e-9 + + +def test_get_coordinates(): + coords = mol.coordinates + assert isinstance(coords, np.ndarray) + assert coords.shape == (2, 3) + + +def test_set_atoms(): + mol_copy = deepcopy(mol) + + mol_copy.atoms = [h1] + assert mol_copy.n_atoms == 1 + assert len(mol_copy.atoms) == 1 + + +def test_set_coords(): + mol_copy = deepcopy(mol) + + new_coords = np.array([[0.0, 0.0, 1.0], [0.0, 0.0, 0.0]]) + + mol_copy.coordinates = new_coords + + assert ( + np.linalg.norm(mol_copy.atoms[0].coord - np.array([0.0, 0.0, 1.0])) + < 1e-9 + ) + assert ( + np.linalg.norm(mol_copy.atoms[1].coord - np.array([0.0, 0.0, 0.0])) + < 1e-9 + ) + + +def test_set_gradients(): + test_mol = Species(name="H2", atoms=[h1, h2], charge=0, mult=1) + + # Gradient must be a Nx3 array for N atoms + with pytest.raises(ValueError): + test_mol.gradient = 5 + + with pytest.raises(ValueError): + test_mol.gradient = np.zeros(shape=(test_mol.n_atoms, 2)) + + # but can set them with a Gradients array + test_mol.gradient = Gradient( + np.zeros(shape=(test_mol.n_atoms, 3)), units="Ha Å^-1" + ) + assert test_mol.gradient.units == ha_per_ang + + # setting from a numpy array defaults to Ha/Å units + test_mol.gradient = np.zeros(shape=(2, 3)) + assert test_mol.gradient.units == ha_per_ang + + # will reshape numpy array if possible + arr = np.random.rand(6) + test_mol.gradient = arr + assert np.allclose(test_mol.gradient, arr.reshape(-1, 3)) + + +def test_species_solvent(): + assert mol.solvent is None + + solvated_mol = Species( + name="H2", atoms=[h1, h2], charge=0, mult=1, solvent_name="water" + ) + assert isinstance(solvated_mol.solvent, Solvent) + + solvated_mol.solvent = None + assert solvated_mol.solvent is None + + solvated_mol.solvent = "water" + assert isinstance(solvated_mol.solvent, Solvent) + + +def test_reorder(): + hf = Species( + name="HF", charge=0, mult=1, atoms=[Atom("H"), Atom("F", x=1)] + ) + + assert hf.atoms[0].label == "H" and hf.atoms[1].label == "F" + + # A simple reorder should swap the atoms + hf.reorder_atoms(mapping={0: 1, 1: 0}) + assert hf.atoms[0].label == "F" and hf.atoms[1].label == "H" + + # Cannot reorder if the atoms if the mapping isn't 1-1 + with pytest.raises(ValueError): + hf.reorder_atoms(mapping={0: 1, 1: 1}) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "species.zip")) +def test_species_single_point(): + mol.single_point(method=orca) + assert mol.energy == -1.138965730007 + + failed_sp_mol = Species(name="H2_failed", atoms=[h1, h2], charge=0, mult=1) + + with pytest.raises(CalculationException): + failed_sp_mol.single_point(method=orca) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "species.zip")) +def test_species_optimise(): + orca.path = here + assert orca.is_available + + dihydrogen = Species( + name="H2", atoms=[Atom("H"), Atom("H", x=1)], charge=0, mult=1 + ) + + dihydrogen.optimise(method=orca) + assert dihydrogen.atoms is not None + + # Resetting the graph after the optimisation should still have a single + # edge as the bond between H atoms + dihydrogen.optimise(method=orca, reset_graph=True) + assert len(dihydrogen.graph.edges) == 1 + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "species.zip")) +def test_find_lowest_energy_conformer(): + # Spoof XTB availability + xtb.path = here + + propane = Molecule(name="propane", smiles="CCC") + + propane.find_lowest_energy_conformer(lmethod=xtb) + assert len(propane.conformers) > 0 + + # Finding low energy conformers should set the energy of propane + assert propane.energy is not None + assert propane.atoms is not None + + +def test_species_copy(): + species = Species(name="h", charge=0, mult=2, atoms=[Atom("H")]) + + species_copy = species.copy() + species_copy.charge = 1 + + assert species.charge != species_copy.charge + + species_copy.mult = 3 + assert species.mult != species_copy.mult + + atom = species_copy.atoms[0] + atom.translate(vec=np.array([1.0, 1.0, 1.0])) + assert np.linalg.norm(species.atoms[0].coord - atom.coord) > 1 + + +def test_species_formula(): + assert mol.formula == "H2" + + mol_no_atoms = Molecule() + assert mol_no_atoms.formula == "" + + +def test_generate_conformers(): + with pytest.raises(NotImplementedError): + mol._generate_conformers() + + +def test_set_lowest_energy_conformer(): + hb = Atom("H", z=0.7) + hydrogen = Species(name="H2", atoms=[h1, hb], charge=0, mult=1) + + hydrogen_wo_e = Species(name="H2", atoms=[h1, hb], charge=0, mult=1) + + hydrogen_with_e = Species(name="H2", atoms=[h1, hb], charge=0, mult=1) + hydrogen_with_e.energy = -1 + + hydrogen.conformers = [hydrogen_wo_e, hydrogen_with_e] + hydrogen._set_lowest_energy_conformer() + + # Conformers without energy should be skipped + assert hydrogen.energy == -1 + + # Conformers with a different molecular graph should be skipped + h_atom = Species(name="H", atoms=[Atom("H")], charge=0, mult=1) + h_atom.energy = -2 + hydrogen.conformers = [hydrogen_with_e, h_atom] + + assert hydrogen.energy == -1 + + +@work_in_tmp_dir(filenames_to_copy=[], kept_file_exts=[]) +def test_thermal_cont_without_hess_run(): + calc = Calculation( + name="test", molecule=mol, method=orca, keywords=orca.keywords.hess + ) + mol.energy = -1 + + # Some blank output that exists + calc.output.filename = "test.out" + with open("test.out", "w") as out: + print("test", file=out) + + assert calc.output.exists + + # Calculating the free energy contribution without a correct Hessian + + with pytest.raises(Exception): + mol.calc_g_cont(calc=calc) + + # and similarly with the enthalpic contribution + with pytest.raises(Exception): + mol.calc_h_cont(calc=calc) + + +def test_is_linear(): + h_atom = Species(name="h", atoms=[Atom("H")], charge=0, mult=1) + assert not h_atom.is_linear() + + dihydrogen = Species( + name="h2", atoms=[Atom("H"), Atom("H", x=1)], charge=0, mult=1 + ) + assert dihydrogen.is_linear() + + water = Species( + name="water", + charge=0, + mult=1, + atoms=[ + Atom("O", x=-1.52, y=2.72), + Atom("H", x=-0.54, y=2.72), + Atom("H", x=-1.82, y=2.82, z=-0.92), + ], + ) + assert not water.is_linear() + assert water.is_planar() + + lin_water = Species( + name="linear_water", + charge=0, + mult=1, + atoms=[ + Atom("O", x=-1.52, y=2.72), + Atom("H", x=-1.21, y=2.51, z=1.03), + Atom("H", x=-1.82, y=2.82, z=-0.92), + ], + ) + assert lin_water.is_linear(tol=0.01) + + close_lin_water = Species( + name="linear_water", + charge=0, + mult=1, + atoms=[ + Atom("O", x=-1.52, y=2.72), + Atom("H", x=-0.90, y=2.36, z=0.89), + Atom("H", x=-1.82, y=2.82, z=-0.92), + ], + ) + assert not close_lin_water.is_linear() + + acetylene = Molecule(smiles="C#C") + assert acetylene.is_linear(tol=0.01) + + +def test_unique_conformer_set(): + test_mol = Species(name="H2", atoms=[h1, h2], charge=0, mult=1) + test_mol.energy = -1.0 + + # With the same molecule the conformer list will be pruned to 1 + test_mol.conformers = [test_mol.copy(), test_mol.copy()] + test_mol.conformers.prune_on_energy() + assert len(test_mol.conformers) == 1 + + test_mol.conformers = None + assert type(test_mol.conformers) is Conformers + assert test_mol.n_conformers == 0 + + +def test_unique_conformer_set_energy(): + # or where one conformer has a very different energy + test_mol = Species(name="H2", atoms=[h1, h2], charge=0, mult=1) + test_mol.energy = -1.0 + + test_mol_high_e = test_mol.copy() + test_mol_high_e.energy = 10.0 + test_mol.conformers = [test_mol_high_e, test_mol.copy(), test_mol.copy()] + test_mol.conformers.prune_on_energy(n_sigma=1) + + assert len(test_mol.conformers) == 1 + assert test_mol.conformers[0].energy == -1.0 + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "species.zip")) +def test_hessian_calculation(): + h2o = Species( + name="H2O", + charge=0, + mult=1, + atoms=[ + Atom("O", -0.0011, 0.3631, -0.0), + Atom("H", -0.8250, -0.1819, -0.0), + Atom("H", 0.8261, -0.1812, 0.0), + ], + ) + + # Spoof ORCA install + orca.path = here + assert orca.is_available + + h2o._run_hess_calculation(method=orca) + assert h2o.hessian is not None + assert h2o.frequencies is not None + + +def test_numerical_hessian_invalid_delta(): + with pytest.raises(ValueError): + mol.calc_hessian(method=orca, coordinate_shift="a", numerical=True) + + +def test_enthalpy_doc_example(): + _h2 = Molecule(smiles="[H][H]") + _h2.energies.append(EnthalpyCont(0.0133, units="Ha")) + _h2.energy = PotentialEnergy( + -1.16397, units="Ha", method=orca, keywords=orca.keywords.opt + ) + assert np.isclose(_h2.enthalpy, -1.15067, atol=1e-4) + + _h2.energy = PotentialEnergy( + -1.16827, units="Ha", method=orca, keywords=orca.keywords.sp + ) + + assert np.isclose(_h2.enthalpy, -1.15497, atol=1e-4) + + +def test_species_rotation_preserves_internals(): + methane = Molecule(smiles="C") + init_coords = methane.coordinates + + axes = [ + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [1.0, 1.0, 1.0], # Need-not be normalised + [2.0, 0.1, 0.3], + ] + thetas = [0.1, 1.0, 3.14159, 5.6] + + for axis in axes: + for theta in thetas: + methane.rotate(axis=axis, theta=theta) + # Rotation should preserve the relative positions i.e. a small RMSD + assert calc_rmsd(methane.coordinates, init_coords) < 0.01 + + # Shift back to original coordinates + methane.coordinates = init_coords + + +def test_species_rotation_is_same_as_atom(): + water = Molecule(smiles="O") + water_atoms = water.atoms.copy() + + axis, angle = [0.2, 0.7, -0.3], 2.41 + water.rotate(axis=axis, theta=angle) + for atom in water_atoms: + atom.rotate(axis=axis, theta=angle) + + assert np.linalg.norm((water.coordinates - water_atoms.coordinates)) < 0.01 + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "species.zip")) +def test_keywords_opt_sp_thermo(): + h2o = Molecule(smiles="O", name="water_tmp") + orca.path = here + assert orca.is_available + + # Check that the calculations work with keywords specified as either a + # regular list or as a single string + for kwds in (["Opt", "def2-SVP", "PBE"], "Opt def2-SVP PBE"): + h2o.energies.clear() + h2o.optimise(method=orca, keywords=kwds) + assert h2o.energy is not None + + for kwds in (["SP", "def2-SVP", "PBE"], "SP def2-SVP PBE"): + h2o.energies.clear() + h2o.single_point(method=orca, keywords=kwds) + assert h2o.energy is not None + + for kwds in (["Freq", "def2-SVP", "PBE"], "Freq def2-SVP PBE"): + h2o.energies.clear() + h2o.hessian = None + h2o.calc_thermo(method=orca, keywords=kwds) + assert h2o.energy is not None + + +def test_flat_species_has_reasonable_coordinates(): + c2h4 = Molecule( + atoms=[ + Atom("C", -4.99490, 1.95320, 0.00000), + Atom("C", -4.74212, 0.64644, 0.00000), + Atom("H", -4.17835, 2.66796, 0.00000), + Atom("H", -6.01909, 2.31189, 0.00000), + Atom("H", -3.71793, 0.28776, -0.00000), + Atom("H", -5.55867, -0.06831, 0.00000), + ] + ) + + assert c2h4.has_reasonable_coordinates + + rh_h4 = Molecule( + atoms=[ + Atom("Rh", -0.19569, -2.70701, -0.00000), + Atom("H", -0.62458, -1.07785, 0.00000), + Atom("H", -1.82557, -3.13312, -0.00000), + Atom("H", 1.43448, -2.28115, 0.00000), + Atom("H", 0.23390, -4.33620, -0.00000), + ] + ) + + assert rh_h4.graph.number_of_edges() == 4 + + # [Rh(H)4] does have a reasonable flat structure, as a square planar + # geometry is possible + assert rh_h4.has_reasonable_coordinates + + +def test_species_does_not_have_reasonable_coordinates(): + ch4_flat = Molecule( + atoms=[ + Atom("C", 0.0, 0.0, 0.0), + Atom("H", 0.0, -1.0, 0.0), + Atom("H", 0.0, 1.0, 0.0), + Atom("H", 0.0, 0.0, -1.0), + Atom("H", 0.0, 0.0, 1.0), + ] + ) + + # CH4 should not be flat + assert not ch4_flat.has_reasonable_coordinates + + x = ch4_flat.coordinates + assert np.min(distance_matrix(x, x) + np.eye(5)) > 0.7 + + +@testutils.requires_working_xtb_install +def test_calc_thermo_not_run_calculation(): + m = Molecule(smiles="O") + calc = Calculation( + name="water", molecule=m, method=xtb, keywords=xtb.keywords.hess + ) + # run() has not been called + with pytest.raises(Exception): + m.calc_thermo(calc=calc) + + +@pytest.mark.parametrize("mult", [1, 3, 5]) +def test_argon_has_valid_spin_state(mult: int, charge: int = 0): + assert Molecule( + atoms=[Atom("Ar")], mult=mult, charge=charge + ).has_valid_spin_state + + +@pytest.mark.parametrize("mult", [1, 3, 4]) +def test_hydrogen_has_invalid_spin_state(mult: int, charge: int = 0): + assert not Molecule( + atoms=[Atom("H")], mult=mult, charge=charge + ).has_valid_spin_state + + +def test_has_valid_spin_state_docstring(): + assert not Molecule( + atoms=[Atom("H")], charge=0, mult=1 + ).has_valid_spin_state + assert Molecule(atoms=[Atom("H")], charge=-1, mult=1).has_valid_spin_state + + +@pytest.mark.parametrize("invalid_mult", [0, -1, "a", (0, 2)]) +def test_cannot_set_multiplicity_to_invalid_value(invalid_mult): + m = Species(name="H2", atoms=[h1, h2], charge=0, mult=1) + with pytest.raises(Exception): + m.mult = invalid_mult + + +@work_in_tmp_dir() +def test_cant_init_with_both_xyz_and_smiles(): + filename = "tmp.xyz" + tmp_mol = Molecule(smiles="O") + tmp_mol.print_xyz_file(filename=filename) + + with pytest.raises(AssertionError): + _ = Molecule("tmp.xyz", smiles="[H]S[H]") + + +@work_in_tmp_dir() +def test_species_load_from_xyz_file_retains_spin_mult_and_solvent(): + filename = "tmp.xyz" + tmp_mol = Molecule(smiles="[O+]", mult=2, charge=1, solvent_name="water") + tmp_mol.print_xyz_file(filename=filename) + + loaded_mol = Molecule(filename) + assert loaded_mol.mult == 2 + assert loaded_mol.charge == 1 + assert loaded_mol.solvent == get_solvent( + solvent_name="water", kind="implicit" + ) + assert loaded_mol.solvent.is_implicit diff --git a/autodE/source/tests/test_substitution.py b/autodE/source/tests/test_substitution.py new file mode 100644 index 0000000000000000000000000000000000000000..c628f9cd808f727346537bf50f6acce8a6fe4fc4 --- /dev/null +++ b/autodE/source/tests/test_substitution.py @@ -0,0 +1,76 @@ +import os +from autode.species.molecule import Reactant +from autode.species.complex import ReactantComplex +from autode.atoms import Atom +from autode.bond_rearrangement import BondRearrangement +from autode.substitution import ( + get_substc_and_add_dummy_atoms, + attack_cost, + SubstitutionCentre, +) + +here = os.path.dirname(os.path.abspath(__file__)) + + +ch3cl = Reactant( + charge=0, + mult=1, + atoms=[ + Atom("Cl", 1.63664, 0.02010, -0.05829), + Atom("C", -0.14524, -0.00136, 0.00498), + Atom("H", -0.52169, -0.54637, -0.86809), + Atom("H", -0.45804, -0.50420, 0.92747), + Atom("H", -0.51166, 1.03181, -0.00597), + ], +) + +f = Reactant(charge=-1, mult=1, atoms=[Atom("F", 4.0, 0.0, 0.0)]) +reac_complex = ReactantComplex(f, ch3cl) + +bond_rearr = BondRearrangement(breaking_bonds=[(2, 1)], forming_bonds=[(0, 2)]) + + +def test_subst_centre(): + subst_centers = get_substc_and_add_dummy_atoms( + reactant=reac_complex, bond_rearrangement=bond_rearr, shift_factor=2 + ) + # Only one atom gets attacked in an SN2 + assert len(subst_centers) == 1 + + sc = subst_centers[0] + assert type(sc) is SubstitutionCentre + assert reac_complex.atoms[sc.a_atom].label == "F" + assert reac_complex.atoms[sc.c_atom].label == "C" + assert reac_complex.atoms[sc.x_atom].label == "Cl" + + # The attacking flouride ion doesn't have any nearest neighbours + assert len(sc.a_atom_nn) == 0 + + # The attacking atom to substitution centre atom ideal bond length + # should be ~ 3 Å + assert 2.0 < sc.r0_ac < 5.0 + + +def test_attack_cost(): + subst_centers = get_substc_and_add_dummy_atoms( + reactant=reac_complex, bond_rearrangement=bond_rearr, shift_factor=2 + ) + + ideal_complex = ReactantComplex(f, ch3cl) + ideal_complex.atoms = [ + Atom("F", -2.99674, -0.35248, 0.17493), + Atom("Cl", 1.63664, 0.02010, -0.05829), + Atom("C", -0.14524, -0.00136, 0.00498), + Atom("H", -0.52169, -0.54637, -0.86809), + Atom("H", -0.45804, -0.50420, 0.92747), + Atom("H", -0.51166, 1.03181, -0.00597), + ] + + cost = attack_cost(reac_complex, subst_centers, attacking_mol_idx=0) + ideal_attack_cost = attack_cost( + ideal_complex, subst_centers, attacking_mol_idx=0 + ) + + # The cost function should be larger for the randomly located reaction + # complex compared to the ideal + assert cost >= ideal_attack_cost diff --git a/autodE/source/tests/test_thermochem.py b/autodE/source/tests/test_thermochem.py new file mode 100644 index 0000000000000000000000000000000000000000..4f5f9be08b2051937a0dace2806649012f0e627c --- /dev/null +++ b/autodE/source/tests/test_thermochem.py @@ -0,0 +1,307 @@ +import os +import pytest +import numpy as np + +from autode import Molecule, Atom, Calculation, HessianKeywords +from autode.transition_states import TSguess, TransitionState +from autode.thermochemistry import calculate_thermo_cont +from autode.input_output import xyz_file_to_atoms +from autode.values import Energy +from autode.species import Species +from autode.methods import ORCA, G09 +from . import testutils +from autode.thermochemistry.igm import _q_rot_igm, _s_rot_rr, _zpe + +here = os.path.dirname(os.path.abspath(__file__)) + +orca = ORCA() +g09 = G09() + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "symm.zip")) +def test_symmetry_number(): + assert Molecule().symmetry_number == 1 + + assert Molecule("BH3.xyz").symmetry_number == 6 + assert Molecule("C6H6.xyz").symmetry_number == 12 + assert Molecule("CO.xyz").symmetry_number == 1 + assert Molecule("CO2.xyz").symmetry_number == 2 + assert Molecule("H2O.xyz").symmetry_number == 2 + assert Molecule("H3N.xyz").symmetry_number == 3 + assert Molecule(smiles="C").symmetry_number == 12 + + # Symmetry numbers aren't calculated for large molecules + h_100 = Species("tmp", atoms=100 * [Atom("H")], charge=1, mult=1) + assert h_100.symmetry_number == 1 + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "thermochem.zip")) +def test_h2o(): + h2o = Molecule(smiles="O") + + calc = Calculation( + name="tmp", molecule=h2o, method=orca, keywords=orca.keywords.hess + ) + calc.set_output_filename("H2O_hess_orca.out") + assert calc.output.exists + + # Check that we cannot calculate the thermochemistry with an undefined + # method/standard state + with pytest.raises(ValueError): + h2o.calc_thermo(calc=calc, lfm_method="an unknown method", sn=1) + + with pytest.raises(ValueError): + h2o.calc_thermo(calc=calc, ss="1nm", sn=1) + + # Calculate using the default method from ORCA + h2o.calc_thermo(calc=calc, ss="1atm", sn=1) + + # Ensure the calculated free energy contribution is close to value obtained + # directly from ORCA + assert h2o.g_cont is not None + assert np.isclose( + h2o.g_cont, + Energy(0.00327564, units="ha"), + atol=Energy(0.1, units="kcal mol-1").to("ha"), + ) + + # and likewise for the enthalpy + assert np.isclose( + h2o.h_cont, + Energy(0.02536189087, units="ha"), + atol=Energy(0.1, units="kcal mol-1").to("ha"), + ) + + # Check that the standard state correction is applied correctly + h2o_1m = Molecule(smiles="O") + h2o_1m.calc_thermo(calc=calc, ss="1M", sn=1) + + # with a difference of ~1.9 kcal mol-1 at room temperature + g_diff = (h2o_1m.g_cont - h2o.g_cont).to("kcal mol-1") + assert np.isclose(g_diff - Energy(1.9, units="kcal mol-1"), 0.0, atol=0.2) + + # but the enthalpy is the same + assert np.isclose(h2o_1m.h_cont, h2o.h_cont, atol=1e-6) + + # Cannot calculate any other standard states + with pytest.raises(ValueError): + h2o.calc_thermo(calc=calc, ss="1nm3", sn=1) + + +def test_single_atom(): + f_entropy_g09 = Energy(0.011799 / 298.15, units="Ha") # T S from g09 + + f_atom = Molecule(atoms=[Atom("F")]) + f_atom.calc_thermo() + f_entropy = (f_atom.h_cont - f_atom.g_cont) / 298.15 + + # Ensure the calculated and 'actual' from Gaussian09 are close + assert np.isclose(f_entropy_g09, f_entropy, atol=2e-5) + + # Ensure the rotational partition functions and entropy are 1 and 0 + assert np.isclose(_q_rot_igm(f_atom, temp=298, sigma_r=0), 1.0) + assert np.isclose(_s_rot_rr(f_atom, temp=298, sigma_r=0), 0.0) + + assert np.isclose(_zpe(f_atom), 0.0) + + assert np.isclose(f_atom.zpe, 0.0) + + +def test_no_atoms(): + mol = Molecule() + assert mol.g_cont is None and mol.h_cont is None + + # Nothing to be calculated for a molecule with no atoms + calculate_thermo_cont(mol) + assert mol.g_cont is None and mol.h_cont is None + + +def test_no_frequencies(): + mol = Molecule(smiles="O") + + # Cannot calculate the vibrational component without vibrational + # frequencies + with pytest.raises(ValueError): + calculate_thermo_cont(mol) + + assert mol.zpe is None + + +def test_linear_non_linear_rot(): + h2_tri = Molecule(atoms=[Atom("H"), Atom("H", x=1), Atom("H", x=1, y=1)]) + h2_lin = Molecule(atoms=[Atom("H"), Atom("H", x=1), Atom("H", x=2)]) + + assert h2_lin.is_linear() + + # Non linear molecules have slightly more entropy than linear ones + assert _s_rot_rr(h2_tri, temp=298, sigma_r=1) > _s_rot_rr( + h2_lin, temp=298, sigma_r=1 + ) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "thermochem.zip")) +def test_freq_shift(): + # Needs to have lots of atoms so there are frequencies <100 cm-1 + alkane = Molecule(smiles="CCCCCCC") + alkane_s = alkane.copy() + + calc = Calculation( + name="tmp", molecule=alkane, method=g09, keywords=g09.keywords.hess + ) + calc.set_output_filename("C7H16_hess_g09.log") + assert calc.output.exists + + alkane.calc_thermo(calc=calc, ss="1atm", sn=1, lfm_method="igm") + + alkane_s.calc_thermo(calc=calc, ss="1atm", sn=1, lfm_method="truhlar") + + # Scaling the frequencies to a defined value using truhlar's method should + # make the entropic contribution less, thus the free energy should be + # larger (G = H - TS) + assert alkane.g_cont < alkane_s.g_cont + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "thermochem.zip")) +def test_acetylene(): + mol = Molecule("C2H2.xyz") + assert np.isclose(mol.weight.to("amu"), 26.01565, atol=0.03) + + calc = Calculation( + name="tmp", molecule=mol, method=g09, keywords=g09.keywords.hess + ) + calc.set_output_filename("C2H2_hess_g09.log") + + # Calculate the thermochemical contributions in the same way as G09 + mol.calc_thermo(calc=calc, temp=298.150, ss="1atm", lfm_method="igm", sn=1) + + # Check that the vibrational frequencies are similar + g09_vib_freqs = [ + 694.3255, + 694.3255, + 780.9635, + 780.9635, + 2085.2098, + 3430.9110, + 3534.0987, + ] + + for freq, g09_freq in zip(mol.vib_frequencies, g09_vib_freqs): + assert np.isclose(freq.to("cm-1"), g09_freq, atol=1.5) + + # Ensure the calculated values are close to the Gaussian 09 values + assert np.isclose(mol.g_cont.to("Ha"), 0.007734, atol=1e-5) + assert np.isclose(mol.h_cont.to("Ha"), 0.031043, atol=1e-5) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "thermochem.zip")) +def test_sn2_ts(): + ts = TransitionState( + TSguess(atoms=xyz_file_to_atoms("TS_sn2.xyz"), charge=-1) + ) + + calc = Calculation( + name="tmp", molecule=ts, method=g09, keywords=g09.keywords.hess + ) + calc.set_output_filename("TS_sn2.log") + + ts.calc_thermo(calc=calc, temp=298.15, ss="1atm", sn=1, lfm_method="igm") + + # One 'vibrational' mode is the imaginary frequency which is discarded + # when calculating thermochemistry + assert len(ts.vib_frequencies) == 3 * ts.n_atoms - 6 - 1 + + # NOTE: Tolerance is 0.3 kcal mol-1 as, for some reason the Gaussian09 + # rotational entropy is not exactly in agreement + assert np.isclose(ts.g_cont.to("Ha"), 0.010382, atol=5e-4) + assert np.isclose(ts.h_cont.to("Ha"), 0.042567, atol=5e-4) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "thermochem.zip")) +def test_long_alkane(): + mol = Molecule("alkane_hess_orca.xyz") + calc = Calculation( + name="tmp", molecule=mol, method=orca, keywords=orca.keywords.hess + ) + + # Should be able to extract from just a .hess file + calc.set_output_filename("alkane_hess_orca.hess") + + mol.calc_thermo( + calc=calc, temp=298.150, ss="1atm", lfm_method="grimme", sn=1 + ) + + # Should be close to a previously computed value + assert np.isclose(mol.g_cont.to("Ha"), 0.2113890180337356, atol=5.2e-4) + + # and <0.5 kcal mol-1 to the ORCA-calculated value + assert np.isclose(mol.g_cont.to("Ha"), 0.21141149, atol=5e-4) + + +def test_unknown_entropy_method(): + h2 = Molecule(atoms=[Atom("H"), Atom("H", x=0.7)]) + + with pytest.raises(KeyError): + _ = calculate_thermo_cont( + species=h2, + lfm_method="an_unkown_method", + temp=298, + ss="1M", + shift=100, + w0=100, + alpha=4, + sigma_r=1, + ) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "thermochem.zip")) +def test_calc_thermo_with_keywords(): + water = Molecule(smiles="O", name="water_pbe") + water.calc_thermo( + keywords=HessianKeywords(["PBE", "def2-SVP", "Freq"]), method=orca + ) + + assert os.path.exists("water_pbe_hess_orca.inp") + inp_line = open("water_pbe_hess_orca.inp", "r").readline() + assert "PBE " in inp_line + + assert water.enthalpy is not None + + # Ensure the ZPE is close to the expected value + assert np.isclose(water.zpe.to("Ha"), 0.01952143, atol=1e-5) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "thermochem.zip")) +def test_calc_thermo_with_calc(): + mol = Molecule(smiles="[H][H]", name="h2_calc") + + calc = Calculation( + "h2_calc_hess", + method=orca, + keywords=HessianKeywords(["B3LYP", "def2-SVP", "Freq"]), + molecule=mol, + ) + + # Should run and non-run calculation + mol.calc_thermo(calc=calc) + assert mol.enthalpy is not None + + assert os.path.exists("h2_calc_hess_orca.inp") + assert "B3LYP " in open("h2_calc_hess_orca.inp", "r").readline() + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "thermochem.zip")) +def test_themochem_minenkov_is_close_to_grimme(): + mol = Molecule("alkane_hess_orca.xyz") + calc = Calculation( + name="tmp", molecule=mol, method=orca, keywords=orca.keywords.hess + ) + calc.set_output_filename("alkane_hess_orca.hess") + + mol.calc_thermo(calc=calc, ss="1atm", sn=1, lfm_method="grimme") + G_grimme = mol.g_cont + + mol.calc_thermo(calc=calc, ss="1atm", sn=1, lfm_method="minenkov") + G_minenkov = mol.g_cont + + mad = abs(float(G_grimme.to("kcal mol-1") - G_minenkov.to("kcal mol-1"))) + assert 1e-5 < mad < 1 # Absolute differnce should be small but non zero diff --git a/autodE/source/tests/test_truncation.py b/autodE/source/tests/test_truncation.py new file mode 100644 index 0000000000000000000000000000000000000000..5f41efb9b9e981b66083b8df759616dd0e38cb2f --- /dev/null +++ b/autodE/source/tests/test_truncation.py @@ -0,0 +1,227 @@ +from autode.transition_states.truncation import get_truncated_species +from autode.bond_rearrangement import BondRearrangement +from autode.input_output import xyz_file_to_atoms +from autode.mol_graphs import is_isomorphic +from autode.species.complex import ReactantComplex +from autode.species.molecule import Reactant +from autode.atoms import Atom +from . import testutils +import os + +here = os.path.dirname(os.path.abspath(__file__)) + + +methane = Reactant( + name="methane", + charge=0, + mult=1, + atoms=[ + Atom("C", 0.93919, -0.81963, 0.00000), + Atom("H", 2.04859, -0.81963, -0.00000), + Atom("H", 0.56939, -0.25105, 0.87791), + Atom("H", 0.56938, -1.86422, 0.05345), + Atom("H", 0.56938, -0.34363, -0.93136), + ], +) + +ethene = Reactant( + name="ethene", + charge=0, + mult=1, + atoms=[ + Atom("C", 0.84102, -0.74223, 0.00000), + Atom("C", -0.20368, 0.08149, 0.00000), + Atom("H", 1.63961, -0.61350, -0.72376), + Atom("H", 0.90214, -1.54881, 0.72376), + Atom("H", -0.26479, 0.88807, -0.72376), + Atom("H", -1.00226, -0.04723, 0.72376), + ], +) + +propene = Reactant( + name="propene", + charge=0, + mult=1, + atoms=[ + Atom("C", 1.06269, -0.71502, 0.09680), + Atom("C", 0.01380, 0.10714, 0.00458), + Atom("H", 0.14446, 1.16840, -0.18383), + Atom("H", -0.99217, -0.28355, 0.11871), + Atom("C", 2.47243, -0.22658, -0.05300), + Atom("H", 0.89408, -1.77083, 0.28604), + Atom("H", 2.51402, 0.86756, -0.24289), + Atom("H", 2.95379, -0.75333, -0.90290), + Atom("H", 3.03695, -0.44766, 0.87649), + ], +) + +but1ene = Reactant( + name="but-1-ene", + charge=0, + mult=1, + atoms=[ + Atom("C", 1.32424, -0.75672, 0.09135), + Atom("C", 0.19057, -0.05301, 0.04534), + Atom("H", 0.20022, 1.00569, -0.19632), + Atom("H", -0.75861, -0.53718, 0.25084), + Atom("C", 2.64941, -0.10351, -0.19055), + Atom("H", 1.27555, -1.81452, 0.33672), + Atom("C", 3.79608, -1.10307, -0.07851), + Atom("H", 2.81589, 0.72011, 0.53717), + Atom("H", 2.63913, 0.32062, -1.21799), + Atom("H", 3.83918, -1.52715, 0.94757), + Atom("H", 4.75802, -0.59134, -0.29261), + Atom("H", 3.66134, -1.92824, -0.81028), + ], +) + +benzene = Reactant(name="benzene", charge=0, mult=1, smiles="c1ccccc1") + +ethanol = Reactant( + name="ethanol", + charge=0, + mult=1, + atoms=[ + Atom("C", -1.12058, -0.88145, -0.01072), + Atom("C", 0.06169, 0.07347, -0.11534), + Atom("H", -1.23059, -1.23894, 1.03497), + Atom("H", -0.96469, -1.75405, -0.67985), + Atom("H", -2.05248, -0.35802, -0.31150), + Atom("O", 1.25088, -0.57948, 0.23621), + Atom("H", -0.09854, 0.96628, 0.53077), + Atom("H", 0.15114, 0.43369, -1.16189), + Atom("H", 1.26514, -0.63012, 1.22767), + ], +) + +methlyethylether = Reactant( + name="ether", + charge=0, + mult=1, + atoms=[ + Atom("C", -1.25448, -0.89454, -0.18195), + Atom("C", -0.05755, 0.05009, -0.17717), + Atom("H", -1.39475, -1.34927, 0.82063), + Atom("H", -1.09810, -1.70182, -0.92836), + Atom("H", -2.17364, -0.33138, -0.44843), + Atom("O", 1.13580, -0.66150, 0.09251), + Atom("H", -0.23702, 0.89804, 0.52589), + Atom("H", 0.03878, 0.50740, -1.18451), + Atom("C", 1.44492, -0.60526, 1.46737), + Atom("H", 2.35098, -1.21969, 1.64663), + Atom("H", 0.63086, -1.03969, 2.08801), + Atom("H", 1.68693, 0.43411, 1.78118), + ], +) + + +def test_core_strip(): + bond_rearr = BondRearrangement(breaking_bonds=[(0, 1)]) + + stripped = get_truncated_species(methane, bond_rearr) + # Should not strip any atoms if the carbon is designated as active + assert stripped.n_atoms == 5 + + stripped = get_truncated_species(ethene, bond_rearr) + assert stripped.n_atoms == 6 + + bond_rearr = BondRearrangement(breaking_bonds=[(1, 3)]) + # Propene should strip to ethene if the terminal C=C is the active atom + stripped = get_truncated_species(propene, bond_rearr) + assert stripped.n_atoms == 6 + assert is_isomorphic(stripped.graph, ethene.graph) + + # But-1-ene should strip to ethene if the terminal C=C is the active atom + stripped = get_truncated_species(but1ene, bond_rearr) + assert stripped.n_atoms == 6 + assert is_isomorphic(stripped.graph, ethene.graph) + + # Benzene shouldn't be truncated at all + stripped = get_truncated_species(benzene, bond_rearr) + assert stripped.n_atoms == 12 + + bond_rearr = BondRearrangement(breaking_bonds=[(0, 1)]) + # Ethanol with the terminal C as the active atom should not replace the OH + # with a H + stripped = get_truncated_species(ethanol, bond_rearr) + assert stripped.n_atoms == 9 + + # Ether with the terminal C as the active atom should replace the OMe with + # OH + stripped = get_truncated_species(methlyethylether, bond_rearr) + assert stripped.n_atoms == 9 + assert is_isomorphic(stripped.graph, ethanol.graph) + + +def test_reactant_complex_truncation(): + # Non-sensical bond rearrangement + bond_rearr = BondRearrangement( + forming_bonds=[(0, 1)], breaking_bonds=[(0, 5)] + ) + + methane_dimer = ReactantComplex(methane, methane) + + # Should not truncate methane dimer at all + truncated = get_truncated_species(methane_dimer, bond_rearr) + assert truncated.n_atoms == 10 + + +def test_product_complex_truncation(): + # H atom transfer from methane to ethene + bond_rearr = BondRearrangement( + breaking_bonds=[(0, 1)], forming_bonds=[(1, 5)] + ) + + methane_ethene = ReactantComplex(methane, ethene, name="product_complex") + + # Should retain all atoms + truncated = get_truncated_species(methane_ethene, bond_rearr) + assert truncated.n_atoms == 11 + + +def test_enone_truncation(): + enone = Reactant(name="enone", smiles="CC(O)=CC(=O)OC") + reactant = ReactantComplex(enone) + + bond_rearr = BondRearrangement( + breaking_bonds=[(2, 11)], forming_bonds=[(11, 5)] + ) + truncated = get_truncated_species(reactant, bond_rearr) + assert truncated.n_atoms == 10 + assert truncated.graph.number_of_edges() == 9 + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "truncation.zip")) +def test_large_truncation(): + mol = ReactantComplex( + Reactant(name="product", atoms=xyz_file_to_atoms("product.xyz")) + ) + + bond_rearr = BondRearrangement(breaking_bonds=[(7, 8), (14, 18)]) + + assert mol.n_atoms == 50 + + truncated = get_truncated_species( + species=mol, bond_rearrangement=bond_rearr + ) + + assert truncated.n_atoms == 27 + assert truncated.graph.number_of_edges() == 28 + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "truncation.zip")) +def test_two_component_truncation(): + propylbromide = Reactant(name="RBr", atoms=xyz_file_to_atoms("RBr.xyz")) + chloride = Reactant(name="Cl", smiles="[Cl-]") + + mol = ReactantComplex(chloride, propylbromide) + bond_rearr = BondRearrangement( + forming_bonds=[(0, 3)], breaking_bonds=[(3, 4)] + ) + + truncated = get_truncated_species( + species=mol, bond_rearrangement=bond_rearr + ) + + # Should truncate to ethylbromide + Cl- + assert truncated.n_atoms == 9 diff --git a/autodE/source/tests/test_ts/__init__.py b/autodE/source/tests/test_ts/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/autodE/source/tests/test_ts/data/locate_ts.zip b/autodE/source/tests/test_ts/data/locate_ts.zip new file mode 100644 index 0000000000000000000000000000000000000000..0d39c53bb4bdf65abf18ef97c7dcf2735458fffa --- /dev/null +++ b/autodE/source/tests/test_ts/data/locate_ts.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:38462260807c31d67667f4da41599801ef161bc8c30814dd3878e77a45658b9a +size 272880 diff --git a/autodE/source/tests/test_ts/data/mode_checking.zip b/autodE/source/tests/test_ts/data/mode_checking.zip new file mode 100644 index 0000000000000000000000000000000000000000..fa5d83ea0c9dc040d631674377e6548ab48282e7 --- /dev/null +++ b/autodE/source/tests/test_ts/data/mode_checking.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:322bf973c12e990a3193d2d8ca96c9242895ead16086fd70b6079f103fc7fbb6 +size 987061 diff --git a/autodE/source/tests/test_ts/data/ts.zip b/autodE/source/tests/test_ts/data/ts.zip new file mode 100644 index 0000000000000000000000000000000000000000..c743268d990ae6063a498ec20a37a2a5228e6165 --- /dev/null +++ b/autodE/source/tests/test_ts/data/ts.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2ea680007a4687e1a04190e2c347fca048aee3432fe7ee49dc3ce4d8a62b9879 +size 1070083 diff --git a/autodE/source/tests/test_ts/data/ts_adapt_neb.zip b/autodE/source/tests/test_ts/data/ts_adapt_neb.zip new file mode 100644 index 0000000000000000000000000000000000000000..d641532e502ff4a5337e160f8ef6fbfb00169a63 --- /dev/null +++ b/autodE/source/tests/test_ts/data/ts_adapt_neb.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:28944fca7902092c5f6f6e379924554b2d5e8774cf33c9640dd8485faa6b09d9 +size 2298 diff --git a/autodE/source/tests/test_ts/data/ts_guess.zip b/autodE/source/tests/test_ts/data/ts_guess.zip new file mode 100644 index 0000000000000000000000000000000000000000..2420172445d00d8feb2e83bcdb30377dd88ded34 --- /dev/null +++ b/autodE/source/tests/test_ts/data/ts_guess.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c2b067a1a81c2f4cb8c6edf0edfb049b8c84c447751a74885d5ce788d61037ff +size 18088 diff --git a/autodE/source/tests/test_ts/data/ts_template.zip b/autodE/source/tests/test_ts/data/ts_template.zip new file mode 100644 index 0000000000000000000000000000000000000000..1490cf6e679d02e8e17054f3f824ac9118494d63 --- /dev/null +++ b/autodE/source/tests/test_ts/data/ts_template.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6857bfb706ceb1c1adb642f76126bb7e44e0a592afbead4bd0810bfd6528bb65 +size 1113 diff --git a/autodE/source/tests/test_ts/data/ts_truncation.zip b/autodE/source/tests/test_ts/data/ts_truncation.zip new file mode 100644 index 0000000000000000000000000000000000000000..8c708d1e9637970abb6a95a44b0cd669975f2bec --- /dev/null +++ b/autodE/source/tests/test_ts/data/ts_truncation.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8088f94535eb133ffb220d62619a32f7a10616195c62f399600d56df8a4e289e +size 1293865 diff --git a/autodE/source/tests/test_ts/test_mode_checking.py b/autodE/source/tests/test_ts/test_mode_checking.py new file mode 100644 index 0000000000000000000000000000000000000000..d4ab230121203c9eab22337ad8ced3e99bab49ba --- /dev/null +++ b/autodE/source/tests/test_ts/test_mode_checking.py @@ -0,0 +1,112 @@ +import os +import numpy as np + +from autode.species.molecule import Molecule +from autode.calculations import Calculation +from autode.transition_states.base import TSbase +from autode.transition_states.base import imag_mode_generates_other_bonds +from autode.transition_states.base import displaced_species_along_mode +from autode.species.molecule import Reactant +from autode.input_output import xyz_file_to_atoms +from autode.atoms import Atom +from autode.bond_rearrangement import BondRearrangement +from autode.methods import ORCA +from .. import testutils + +here = os.path.dirname(os.path.abspath(__file__)) +orca = ORCA() + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "mode_checking.zip")) +def test_imag_modes(): + assert not has_correct_mode( + "incorrect_ts_mode", bbonds=[(1, 6)], fbonds=[(2, 6)] + ) + + assert has_correct_mode( + "correct_ts_mode", bbonds=[(0, 1), (1, 2)], fbonds=[(0, 2)] + ) + + assert not has_correct_mode( + "incorrect_ts_mode_2", bbonds=[(3, 8), (3, 2)], fbonds=[(2, 8)] + ) + + assert has_correct_mode( + "h_shift_correct_ts_mode", bbonds=[(1, 10)], fbonds=[(5, 10)] + ) + + assert has_correct_mode( + "ene_hess", bbonds=[(0, 5), (2, 1)], fbonds=[(4, 5)] + ) + + assert has_correct_mode( + "curtius", bbonds=[(0, 1), (2, 3)], fbonds=[(0, 2)] + ) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "mode_checking.zip")) +def test_graph_no_other_bonds(): + ts = TSbase( + atoms=xyz_file_to_atoms("h_shift_correct_ts_mode.xyz"), + mult=2, + bond_rearr=BondRearrangement( + breaking_bonds=[(1, 10)], forming_bonds=[(5, 10)] + ), + ) + + calc = Calculation( + name="h_shift", + molecule=ts, + method=orca, + keywords=orca.keywords.opt_ts, + n_cores=1, + ) + calc.set_output_filename("h_shift_correct_ts_mode.out") + + assert ts.hessian is not None + + f_ts = displaced_species_along_mode(ts, mode_number=6, disp_factor=1.0) + b_ts = displaced_species_along_mode(ts, mode_number=6, disp_factor=-1.0) + + assert not imag_mode_generates_other_bonds( + ts=ts, f_species=f_ts, b_species=b_ts + ) + + +def test_disp_molecule_has_same_solvent(): + mol = Molecule(smiles="[H][H]", solvent_name="water") + mol.hessian = np.eye(3 * mol.n_atoms, 3 * mol.n_atoms) + + disp_mol = displaced_species_along_mode( + species=mol, + mode_number=1, + ) + assert disp_mol.solvent is not None + assert disp_mol.solvent == mol.solvent + + +def has_correct_mode(name, fbonds, bbonds): + calc = Calculation( + name=name, + molecule=Reactant(atoms=[Atom("H")], mult=2), + method=orca, + keywords=orca.keywords.opt_ts, + n_cores=1, + ) + # need to bypass the pre-calculation checks on the molecule. e.g. valid spin state + calc.molecule = reactant = Reactant( + name="r", atoms=xyz_file_to_atoms(f"{name}.xyz") + ) + + calc.set_output_filename(f"{name}.out") + + # Don't require all bonds to be breaking/making in a 'could be ts' function + ts = TSbase( + atoms=reactant.atoms, + bond_rearr=BondRearrangement( + breaking_bonds=bbonds, forming_bonds=fbonds + ), + ) + ts.hessian = reactant.hessian + + return ts.imag_mode_has_correct_displacement(req_all=False) diff --git a/autodE/source/tests/test_ts/test_ts_adapt_neb.py b/autodE/source/tests/test_ts/test_ts_adapt_neb.py new file mode 100644 index 0000000000000000000000000000000000000000..5fe5d7f971e678ce5fb8452b489dc5b165053e96 --- /dev/null +++ b/autodE/source/tests/test_ts/test_ts_adapt_neb.py @@ -0,0 +1,88 @@ +import os +import numpy as np +from autode import Atom, Reactant, Product, Reaction +from autode.methods import XTB +from autode.bond_rearrangement import BondRearrangement +from autode.neb import NEB +from autode.transition_states.locate_tss import _get_ts_neb_from_adaptive_path +from .. import testutils + +here = os.path.dirname(os.path.abspath(__file__)) + + +def _sn2_reaction(): + r0 = Reactant( + atoms=[ + Atom("C", -0.1087, -0.0058, -0.0015), + Atom("Cl", 1.6659, 0.0565, -0.0425), + Atom("H", -0.5267, -0.6146, -0.8284), + Atom("H", -0.4802, -0.4519, 0.9359), + Atom("H", -0.5503, 1.0158, -0.0635), + ] + ) + + r1 = Reactant(atoms=[Atom("F")], charge=-1) + p0 = Product( + atoms=[ + Atom("C", -0.0524, -0.0120, 0.0160), + Atom("F", 1.3238, -0.1464, -0.1423), + Atom("H", -0.3175, 0.0493, 1.0931), + Atom("H", -0.3465, 0.9303, -0.4647), + Atom("H", -0.6073, -0.8212, -0.5021), + ] + ) + p1 = Product(atoms=[Atom("Cl")], charge=-1) + + return Reaction(r0, r1, p0, p1, solvent_name="water") + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "ts_adapt_neb.zip")) +@testutils.requires_working_xtb_install +def test_ts_from_neb_optimised_after_adapt(): + rxn = _sn2_reaction() + neb = NEB.from_file("dOr2Us_ll_ad_0-5_0-1_path.xyz") + + assert len(neb.images) > 0 + assert neb.images[0].solvent is not None + + def get_ts_guess(): + return _get_ts_neb_from_adaptive_path( + reactant=rxn.reactant, + product=rxn.product, + method=XTB(), + name="dOr2Us_ll_ad_neb_0-5_0-1", + ad_name="dOr2Us_ll_ad_0-5_0-1", + bond_rearr=BondRearrangement( + forming_bonds=[(0, 5)], breaking_bonds=[(0, 1)] + ), + ) + + ts_guess = get_ts_guess() + assert ts_guess is not None + + # sum of the H-C-H angles should be close to 360º for the correct TS + angles = [ + ts_guess.angle(2, 0, 3), + ts_guess.angle(2, 0, 4), + ts_guess.angle(3, 0, 4), + ] + assert np.isclose(sum([a.to("degrees") for a in angles]), 360, atol=5) + + # if we delete the path then no NEB is possible + os.remove("dOr2Us_ll_ad_0-5_0-1_path.xyz") + assert get_ts_guess() is None + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "ts_adapt_neb.zip")) +def test_no_ts_guess_without_peak_in_ad_path(): + rxn = _sn2_reaction() + ts_guess = _get_ts_neb_from_adaptive_path( + reactant=rxn.reactant, + product=rxn.product, + method=XTB(), + name="dOr2Us_ll_ad_neb_0-5_0-1", + ad_name="no_peak", + bond_rearr=BondRearrangement(), + ) + # No calculations are performed + assert ts_guess is None diff --git a/autodE/source/tests/test_ts/test_ts_base.py b/autodE/source/tests/test_ts/test_ts_base.py new file mode 100644 index 0000000000000000000000000000000000000000..faed77ebab8ea07601101e934e39fcc7f89f72d6 --- /dev/null +++ b/autodE/source/tests/test_ts/test_ts_base.py @@ -0,0 +1,55 @@ +import pytest + +from autode.config import Config +from autode.atoms import Atom +from autode.values import Frequency +from autode.transition_states.ts_guess import TSguess +from autode.species.molecule import Reactant, Product +from autode.bond_rearrangement import BondRearrangement + + +def h3_ts_guess(): + return TSguess(atoms=[Atom("H"), Atom("H", x=0.7), Atom("H", 1.4)], mult=2) + + +def test_invalid_init_different_reactants_products(): + with pytest.raises(ValueError): + _ = TSguess( + atoms=[Atom("H")], + reactant=Reactant(atoms=[Atom("H")], solvent_name="water"), + product=Product(atoms=[Atom("H")], solvent_name="dcm"), + ) + + +def test_invalid_init_different_reactant(): + with pytest.raises(ValueError): + _ = TSguess( + atoms=[Atom("H")], + reactant=Reactant(atoms=[Atom("H")], solvent_name="water"), + solvent_name="dcm", + ) + + +def test_mode_checking_without_bond_rearr(): + ts_guess = h3_ts_guess() + + with pytest.raises(Exception): + _ = ts_guess.could_have_correct_imag_mode + + +def test_could_have_correct_imag_mode(): + class TmpHess: + def __init__(self, freqs: list): + self.frequencies_proj = freqs + + ts_guess = h3_ts_guess() + ts_guess.bond_rearrangement = BondRearrangement( + forming_bonds=[(0, 1)], breaking_bonds=[(1, 2)] + ) + + ts_guess._hess = TmpHess(freqs=[]) # No frequencies + assert ts_guess.imaginary_frequencies is None + assert not ts_guess.could_have_correct_imag_mode + + ts_guess._hess = TmpHess(freqs=[Config.min_imag_freq / 2, Frequency(1000)]) + assert not ts_guess.could_have_correct_imag_mode diff --git a/autodE/source/tests/test_ts/test_ts_guess.py b/autodE/source/tests/test_ts/test_ts_guess.py new file mode 100644 index 0000000000000000000000000000000000000000..b45ed622df56d7caffbfd6c10bd023254b8a2d37 --- /dev/null +++ b/autodE/source/tests/test_ts/test_ts_guess.py @@ -0,0 +1,11 @@ +from autode.atoms import Atom +from autode.species.molecule import Molecule +from autode.transition_states.ts_guess import TSguess + + +def test_that_a_molecules_solvent_is_inherited(): + mol = Molecule(atoms=[Atom("H")], mult=2, solvent_name="water") + assert mol.solvent.smiles == "O" + + ts_guess = TSguess.from_species(mol) + assert ts_guess.solvent.smiles == "O" diff --git a/autodE/source/tests/test_ts/test_ts_template.py b/autodE/source/tests/test_ts/test_ts_template.py new file mode 100644 index 0000000000000000000000000000000000000000..fe093cacc3daa5afcacf899b942edf19d2ed3bc5 --- /dev/null +++ b/autodE/source/tests/test_ts/test_ts_template.py @@ -0,0 +1,245 @@ +import os +import shutil +import numpy as np +from .. import testutils +import pytest +from autode.exceptions import TemplateLoadingFailed +from autode.config import Config +from autode.species.molecule import Molecule +from autode.bond_rearrangement import BondRearrangement +from autode.species.complex import ReactantComplex, ProductComplex +from autode.species.molecule import Reactant, Product +from autode.atoms import Atom +from autode.utils import work_in_tmp_dir +from autode.transition_states.templates import get_ts_templates +from autode.transition_states.templates import get_value_from_file +from autode.transition_states.templates import get_values_dict_from_file +from autode.transition_states.templates import TStemplate +from autode.transition_states.transition_state import TransitionState +from autode.transition_states.ts_guess import TSguess +from autode.mol_graphs import get_truncated_active_mol_graph +from autode.transition_states.ts_guess import get_template_ts_guess +from autode.input_output import xyz_file_to_atoms +from autode.wrappers.XTB import XTB + +here = os.path.dirname(os.path.abspath(__file__)) + + +ch3cl = Reactant( + charge=0, + mult=1, + atoms=[ + Atom("Cl", 1.63664, 0.02010, -0.05829), + Atom("C", -0.14524, -0.00136, 0.00498), + Atom("H", -0.52169, -0.54637, -0.86809), + Atom("H", -0.45804, -0.50420, 0.92747), + Atom("H", -0.51166, 1.03181, -0.00597), + ], +) +f = Reactant(charge=-1, mult=1, atoms=[Atom("F", 4.0, 0.0, 0.0)]) +reac_complex = ReactantComplex(f, ch3cl) + +ch3f = Product( + charge=0, + mult=1, + atoms=[ + Atom("C", -0.05250, 0.00047, -0.00636), + Atom("F", 1.31229, -0.01702, 0.16350), + Atom("H", -0.54993, -0.04452, 0.97526), + Atom("H", -0.34815, 0.92748, -0.52199), + Atom("H", -0.36172, -0.86651, -0.61030), + ], +) +cl = Product(charge=-1, mult=1, atoms=[Atom("Cl", 4.0, 0.0, 0.0)]) +product_complex = ProductComplex(ch3f, cl) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "ts_guess.zip")) +def test_ts_template_save(): + ts_graph = reac_complex.graph.copy() + + # Add the F-C bond as active + ts_graph.add_edge(0, 2, active=True) + + # Remove then re-add the C-Cl bond as active + ts_graph.remove_edge(1, 2) + ts_graph.add_edge(1, 2, active=True) + + truncated_graph = get_truncated_active_mol_graph(ts_graph) + + template = TStemplate(truncated_graph, species=reac_complex) + template.save(folder_path=os.getcwd()) + + assert os.path.exists("template0.txt") + + # With no distances the template shouldn't be valid + with pytest.raises(TemplateLoadingFailed): + _ = TStemplate(filename="template0.txt") + + os.remove("template0.txt") + + truncated_graph.edges[(0, 2)]["distance"] = 1.9 + truncated_graph.edges[(1, 2)]["distance"] = 2.0 + + template.graph = truncated_graph + template.save(folder_path=os.getcwd()) + loaded_template = TStemplate(filename="template_sn2.txt") + + assert loaded_template.solvent is None + assert loaded_template.charge == -1 + assert loaded_template.mult == 1 + + assert loaded_template.graph is not None + assert loaded_template.graph.nodes == truncated_graph.nodes + assert loaded_template.graph.edges == truncated_graph.edges + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "ts_guess.zip")) +def test_ts_template(): + # Spoof XTB install, if not installed + if shutil.which("xtb") is None: + Config.XTB.path = here + + Config.ts_template_folder_path = os.path.join(here, "data", "ts_guess") + + bond_rearr = BondRearrangement( + breaking_bonds=[(2, 1)], forming_bonds=[(0, 2)] + ) + + reac_shift = reac_complex.copy() + + reac_shift.atoms = [ + Atom("F", -3.0587, -0.8998, -0.2180), + Atom("Cl", 0.3842, 0.86572, -1.65507), + Atom("C", -1.3741, -0.0391, -0.9719), + Atom("H", -1.9151, -0.0163, -1.9121), + Atom("H", -1.6295, 0.6929, -0.2173), + Atom("H", -0.9389, -0.9786, -0.6534), + ] + reac_shift.print_xyz_file() + + templates = get_ts_templates() + assert len(templates) == 1 + assert templates[0].graph.number_of_nodes() == 6 + + tsg_template = get_template_ts_guess( + reac_shift, + product_complex, + name="template", + bond_rearr=bond_rearr, + method=XTB(), + ) + + # Reset the folder path to the default + Config.ts_template_folder_path = None + + assert tsg_template is not None + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "ts_guess.zip")) +def test_ts_template_with_scan(): + if shutil.which("xtb") is None or not shutil.which("xtb").endswith("xtb"): + return + + Config.lcode = "xtb" + Config.XTB.path = shutil.which("xtb") + + ts_guess = TSguess( + atoms=[ + Atom("C", -0.29102, 0.31489, -0.00001), + Atom("Cl", 1.48694, 0.31490, 0.00000), + Atom("H", -0.66083, -0.11622, -0.95298), + Atom("H", -0.66086, 1.35574, 0.10314), + Atom("H", -0.66083, -0.29487, 0.84984), + Atom("Cl", -5.20436, 0.68301, -0.00000), + ], + charge=-1, + ) + ts_guess.solvent = "water" + + # Running this constrained optimisation would break without intermediate + # steps, so check that it worksw + ts_guess.run_constrained_opt( + "ll_const_opt", distance_consts={(0, 5): 2.3}, method=XTB() + ) + + # Ensure the correct final distance + assert np.isclose(ts_guess.distance(0, 5), 2.3, atol=0.1) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "ts_template.zip")) +def test_truncated_mol_graph_atom_types(): + ir_ts = TransitionState( + TSguess(atoms=xyz_file_to_atoms("vaskas_TS.xyz"), charge=0, mult=1) + ) + + ir_ts.bond_rearrangement = BondRearrangement( + forming_bonds=[(5, 1), (6, 1)], breaking_bonds=[(5, 6)] + ) + if (5, 1) in ir_ts.graph.edges: + ir_ts.graph.remove_edge(5, 1) + ir_ts.graph.add_edge(5, 1, active=True) + + if (6, 1) in ir_ts.graph.edges: + ir_ts.graph.remove_edge(6, 1) + ir_ts.graph.add_edge(6, 1, active=True) + + if (5, 6) in ir_ts.graph.edges: + ir_ts.graph.remove_edge(5, 6) + ir_ts.graph.add_edge(5, 6, active=True) + + graph = get_truncated_active_mol_graph(ir_ts.graph) + # Should only be a single Ir atom in the template + assert ( + sum(node[1]["atom_label"] == "Ir" for node in graph.nodes(data=True)) + == 1 + ) + + +def test_ts_template_parse(): + # No value + with pytest.raises(TemplateLoadingFailed): + _ = get_value_from_file("solvent", file_lines=["solvent:"]) + + # Key doesn't exist + with pytest.raises(TemplateLoadingFailed): + _ = get_value_from_file("charge", file_lines=["solvent:"]) + _ = get_values_dict_from_file("charge", file_lines=["solvent:"]) + + # Incorrectly formatted values section + with pytest.raises(TemplateLoadingFailed): + _ = get_values_dict_from_file("nodes", file_lines=["0 C", "1 F"]) + + +@work_in_tmp_dir() +def test_ts_templates_find(): + templates = get_ts_templates(folder_path="/a/path/that/doesnt/exist") + assert len(templates) == 0 + + # Create a incorrectly formatted file, i.e. blank + open("wrong_template.txt", "w").close() + templates = get_ts_templates(folder_path=os.getcwd()) + assert len(templates) == 0 + + os.remove("wrong_template.txt") + + +def test_inactive_graph(): + # Should fail to get a active graph from a graph with no active edges + with pytest.raises(ValueError): + _ = get_truncated_active_mol_graph(ch3f.graph) + + template = TStemplate() + assert not template.graph_has_correct_structure() + + template.graph = ch3f.graph.copy() + assert not template.graph_has_correct_structure() + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "ts_template.zip")) +def test_ts_from_species_is_same_as_from_ts_guess(): + ts = TransitionState( + TSguess(atoms=xyz_file_to_atoms("vaskas_TS.xyz"), charge=0, mult=1) + ) + + assert TransitionState.from_species(Molecule("vaskas_TS.xyz")) == ts diff --git a/autodE/source/tests/test_units.py b/autodE/source/tests/test_units.py new file mode 100644 index 0000000000000000000000000000000000000000..24f33313f364af8eb3c2b29737faee0104f220fd --- /dev/null +++ b/autodE/source/tests/test_units.py @@ -0,0 +1,37 @@ +from autode.units import ( + ha, + kjmol, + kcalmol, + ev, + ang, + a0, + nm, + pm, + m, + rad, + deg, + ha_per_ang, + ev_per_ang, + ha_per_a0, +) + + +def test_units(): + assert ha == "ha" + assert ha == "hartree" + + # Ensure units have some base attributes + for unit in (ha, kjmol, kcalmol, ev, ang, a0, nm, pm, m, rad, deg): + assert unit.name != "" + assert str(unit) != "" + assert repr(unit) != "" + assert len(unit.aliases) > 1 + assert unit.plot_name != "" + + +def test_composite_units(): + # More electron volts per angstrom than Hartees + assert 1.0 * ha_per_ang.times < 1.0 * ev_per_ang.times + + # and fewer per bohr + assert 1.0 * ha_per_ang.times > 1.0 * ha_per_a0.times diff --git a/autodE/source/tests/test_utils.py b/autodE/source/tests/test_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..899fd68b3f9cd0bd401b6783c46e79d8ec9f7c19 --- /dev/null +++ b/autodE/source/tests/test_utils.py @@ -0,0 +1,449 @@ +import shutil +import time +import pytest +import platform +import os +from autode import utils +from autode.calculations import Calculation +from autode.species.molecule import Molecule +from autode.conformers import Conformer +from autode.wrappers.MOPAC import MOPAC +from autode.wrappers.keywords import OptKeywords +from subprocess import Popen, TimeoutExpired +import multiprocessing as mp +from autode import exceptions as ex +from autode.mol_graphs import is_isomorphic +from autode.utils import ( + work_in_tmp_dir, + log_time, + requires_graph, + ProcessPool, + temporary_config, +) +from autode.wrappers.keywords.keywords import Functional +from autode.config import Config +from .testutils import requires_working_xtb_install + + +here = os.path.dirname(os.path.abspath(__file__)) + + +def test_clear_files(): + @utils.work_in("test") + def make_test_files(): + open("aaa.tmp", "a").close() + + make_test_files() + assert os.path.exists("test/aaa.tmp") + os.remove("test/aaa.tmp") + os.rmdir("test") + + +def test_reset_dir_on_error(): + @utils.work_in("tmp_path") + def raise_error(): + assert 0 + + here = os.getcwd() + try: + raise_error() + except AssertionError: + pass + + assert here == os.getcwd() + + +def test_monitored_external(): + echo = ["echo", "test"] + if platform.system() == "Windows": + echo = ["cmd", "/c", *echo] # echo is cmd prompt builtin + + utils.run_external_monitored(params=echo, output_filename="test.txt") + + assert os.path.exists("test.txt") + assert "test" in open("test.txt", "r").readline() + os.remove("test.txt") + + # If the break word is in the stdout or stderr then the process should exit + echo = ["echo", "ABORT\ntest"] + if platform.system() == "Windows": + echo = ["cmd", "/c", *echo] + + utils.run_external_monitored( + params=echo, output_filename="test.txt", break_word="ABORT" + ) + + assert len(open("test.txt", "r").readline()) == 0 + os.remove("test.txt") + + +def test_work_in_temp_dir(): + # Make a test python file echoing 'test' and printing a .dat file + with open("echo_test.py", "w") as test_file: + print('print("test")', file=test_file) + print( + 'other_file = open("other_file.dat", "w")\n' + 'other_file.write("test")\n' + "other_file.close", + file=test_file, + ) + + # Working in a temp directory running an external command + @utils.work_in_tmp_dir( + filenames_to_copy=["echo_test.py"], kept_file_exts=[".txt"] + ) + def test(): + params = ["python", "echo_test.py"] + utils.run_external(params=params, output_filename="test.txt") + + # Call the decorated function + test() + + # Decorator should only copy back the .txt file back, and not the .dat + assert os.path.exists("test.txt") + assert not os.path.exists("other_file.dat") + + os.remove("echo_test.py") + os.remove("test.txt") + + +def test_reset_tmp_dir_on_error(): + @utils.work_in_tmp_dir() + def raise_error(): + assert 0 + + here = os.getcwd() + try: + raise_error() + except AssertionError: + pass + + assert here == os.getcwd() + + +@work_in_tmp_dir(filenames_to_copy=[], kept_file_exts=[]) +def test_calc_output(): + calc = Calculation( + name="test", + molecule=Molecule(smiles="C"), + method=MOPAC(), + keywords=OptKeywords(["PM7"]), + ) + + # A function that ficticously requires output + @utils.requires_output + def test(calculation): + print(calculation.molecule.n_atoms) + + with pytest.raises(ex.NoCalculationOutput): + test(calc) + + # Calling the same function with some calculation output should not raise + # a not calculation output error + calc.output.filename = "tmp.out" + with open(calc.output.filename, "w") as out_file: + print("some", "example", "output", sep="\n", file=out_file) + test(calc) + + +def test_conformers(): + methane = Molecule(name="methane", smiles="C") + + # Function requiring a molecule having a conformer attribute + @utils.requires_conformers + def test(mol): + print(mol.conformers[0].n_atoms) + + with pytest.raises(ex.NoConformers): + test(methane) + + # Populating a species conformers should allow this function to be called + methane.conformers = [Conformer(name="conf0", atoms=methane.atoms)] + test(methane) + + +def test_work_in_empty(): + @utils.work_in("tmp_dir") + def test_function(): + # Function makes no files so the directory should be deleted + print("test") + + test_function() + assert not os.path.exists("tmp_dir") + + @utils.work_in("tmp_dir") + def test_function_files(): + # Function makes files so the directory should be deleted + with open("tmp.txt", "w") as out_file: + print("test", file=out_file) + + test_function_files() + # Directory should now be retained + assert os.path.exists("tmp_dir") + + # Remove the created files and directory + os.remove("tmp_dir/tmp.txt") + os.rmdir("tmp_dir") + + +def test_work_in_compatible_with_experimental_timeout(): + if platform.system() != "Windows": + return + # cleanup should take care of all processes unused if the + # function finished within time limit + + @utils.timeout(seconds=3) + def sleep_2s(): + return time.sleep(2) + + @utils.work_in("tmp_dir") + def use_exp_timeout(): + sleep_2s() + + with temporary_config(): + Config.use_experimental_timeout = True + use_exp_timeout() + + +def test_cleanup_after_timeout(): + if platform.system() != "Windows": + return + # cleanup should take care of all processes unused + + @utils.timeout(seconds=3) + def sleep_2s(): + return time.sleep(2) + + with temporary_config(): + Config.use_experimental_timeout = True + sleep_2s() + assert len(mp.active_children()) != 0 + utils.cleanup_after_timeout() + assert len(mp.active_children()) == 0 + + +def test_timeout(): + if platform.system() == "Windows": + Config.use_experimental_timeout = True + + def sleep_2s(): + return time.sleep(2) + + start_time = time.time() + sleep_2s() + assert time.time() - start_time > 1.9 + + @utils.timeout(seconds=1) + def sleep_2s(): + return time.sleep(2) + + # Decorated function should timeout and return in under two seconds + start_time = time.time() + sleep_2s() + assert time.time() - start_time < 2 + + @utils.timeout(seconds=10) + def return_string(): + return "test" + + # Should not raise a TimeoutError if the function executes fast + start_time = time.time() + assert return_string() == "test" + assert time.time() - start_time < 10 + + if platform.system() == "Windows": + Config.use_experimental_timeout = False + + +def test_repeated_timeout_win_loky(): + if platform.system() != "Windows": + return None + # With experimental timeout, triggering timeout + # repeatedly should not cause deadlocks or hangs + # from executor manager thread + + @utils.timeout(seconds=1) + def sleep_2s(): + return time.sleep(2) + + with temporary_config(): + Config.use_experimental_timeout = True + start_time = time.time() + sleep_2s() + sleep_2s() + sleep_2s() + assert time.time() - start_time < 6 + + +@work_in_tmp_dir(filenames_to_copy=[], kept_file_exts=[]) +def test_spawn_multiprocessing_posix(): + if platform.system() == "Windows": + return None + + with open("tmp.py", "w") as py_file: + print( + "import multiprocessing as mp", + 'mp.set_start_method("spawn", force=True)', + "import autode as ade", + "from autode.utils import ProcessPool", + "def mol():", + ' return ade.Molecule(atoms=[ade.Atom("H"), ade.Atom("H", x=0.7)])', + 'if __name__ == "__main__":', + " with ProcessPool(2) as pool:", + " res = [pool.submit(mol) for _ in range(2)]", + " mols = [r.result() for r in res]", + sep="\n", + file=py_file, + ) + + process = Popen(["python", "tmp.py"]) + + # Executing the script should not take more than a second, if the function + # hangs then it should timeout after 20s + try: + process.wait(timeout=20) + except TimeoutExpired: + raise AssertionError + + os.remove("tmp.py") + + +def test_spawn_multiprocessing_graph_posix(): + # Test spawn method for POSIX only + if platform.system() == "Windows": + return None + + mp.set_start_method("spawn", force=True) + + # Isomorphism should still be able to be checked + h2o_a, h2o_b = Molecule(smiles="O"), Molecule(smiles="O") + assert is_isomorphic(h2o_a.graph, h2o_b.graph) + + mp.set_start_method("fork", force=True) + + +def test_spawn_loky_graph_win(): + if platform.system() != "Windows": + return + + import loky + + loky.backend.context.set_start_method("spawn", force=True) + + # Isomorphism should still be able to be checked + h2o_a, h2o_b = Molecule(smiles="O"), Molecule(smiles="O") + assert is_isomorphic(h2o_a.graph, h2o_b.graph) + + loky.backend.context.set_start_method("loky", force=True) + + +def test_config_in_worker_proc(): + # check that the config is able to be passed to child processes + # mainly for windows, but still nice to check for posix + + with temporary_config(): + Config.n_cores = 9 + Config.ORCA.keywords.sp.functional = "B3LYP" + with ProcessPool(max_workers=2) as pool: + job = pool.submit(worker_fn) + _ = job.result() + + +def worker_fn(): + assert Config.n_cores == 9 + assert Config.ORCA.keywords.sp.functional == Functional("B3LYP") + + +def test_temporary_config_context_manager(): + old_n_cores = Config.n_cores + old_orca_funct = Config.ORCA.keywords.sp.functional + + with temporary_config(): + Config.n_cores = 9 + Config.ORCA.keywords.sp.functional = "B3LYP" + # test the values have been changed in external function + worker_fn() + + # Config should be restored after exit + assert Config.n_cores == old_n_cores + assert Config.ORCA.keywords.sp.functional == old_orca_funct + + +def test_temporary_config_in_worker_proc(): + # check that the context manager works if workers + # are created inside the context manager + old_n_cores = Config.n_cores + old_orca_funct = Config.ORCA.keywords.sp.functional + + with temporary_config(): + Config.n_cores = 9 + Config.ORCA.keywords.sp.functional = "B3LYP" + with ProcessPool(max_workers=2) as pool: + job = pool.submit(worker_fn) + _ = job.result() + + assert Config.n_cores == old_n_cores + assert Config.ORCA.keywords.sp.functional == old_orca_funct + + +def test_time_units(): + with pytest.raises(ValueError): + log_time(units="X") # invalid time format + + +def test_requires_graph(): + @requires_graph + def f(mol): + return mol.graph.number_of_edges() + + with pytest.raises(Exception): + m = Molecule() + m.graph = None + + f(m) # No graph + + +def test_tmp_env(): + os.environ["OMP_NUM_THREADS"] = "1" + + @utils.run_in_tmp_environment( + tmp_key_str="tmp_value", tmp_key_int=1, OMP_NUM_THREADS=9999 + ) + def f(): + assert os.environ["tmp_key_int"] == "1" + assert os.environ["tmp_key_str"] == "tmp_value" + assert os.environ["OMP_NUM_THREADS"] == "9999" + + f() + assert "tmp_key_int" not in os.environ + assert "tmp_key_str" not in os.environ + assert os.environ["OMP_NUM_THREADS"] == "1" + + +def test_string_dict(): + d = utils.StringDict("a = b solvent = water") + assert "a" in str(d) + assert "solvent" in str(d) + assert d["a"] == "b" + + +def test_requires_xtb_install(): + path_env_var = os.environ.pop("PATH") + assert shutil.which("xtb") is None + + test_list = [] + + @requires_working_xtb_install + def tmp_function(): + test_list.append("executed") + + # If XTB is not in $PATH then the function should not execute + assert len(test_list) == 0 + os.environ["PATH"] = path_env_var + + # if XTB is in $PATH, the function should execute + xtbpath = shutil.which("xtb") + if xtbpath is not None: + assert xtbpath.lower().endswith(("xtb", "xtb.exe")) + tmp_function() + assert len(test_list) == 1 diff --git a/autodE/source/tests/test_value.py b/autodE/source/tests/test_value.py new file mode 100644 index 0000000000000000000000000000000000000000..159da18394d8a5f33c973f0e163167f8cd1012a3 --- /dev/null +++ b/autodE/source/tests/test_value.py @@ -0,0 +1,309 @@ +import pytest +import numpy as np +from autode.constants import Constants +from autode.units import ha, kjmol, kcalmol, ev, ang, a0, nm, pm, m, rad, deg +from autode.values import ( + _to, + Value, + Distance, + MWDistance, + Angle, + Mass, + Energy, + Energies, + PotentialEnergy, + Enthalpy, + FreeEnergy, + FreeEnergyCont, + EnthalpyCont, + Frequency, + GradientRMS, + Temperature, +) + + +class TmpValue(Value): + def __repr__(self): + return "" + + +def test_base_value(): + val = TmpValue(0.0) + assert val == 0.0 + assert val != None + assert hasattr(val, "units") + + # Same representation as the string + assert repr(val) == "" + + val2 = val.copy() + val += 1 + + assert val2 == 0.0 + + # Values are equal to default numpy isclose precision + # (1e-08 as of 20/05/21) + assert TmpValue(0.0) == TmpValue(1e-10) + + +def test_base_value_numpy_add(): + res = np.array([0.0, 0.0]) + TmpValue(0.1) + assert isinstance(res, np.ndarray) + assert np.allclose(res, np.array([0.1, 0.1]), atol=1e-10) + + +def test_energy(): + with pytest.raises(ValueError): + Energy(0.0, units="not_an_energy_unit") + + e1 = Energy(0.0) + assert "energy" in repr(e1).lower() + assert type(e1.method_str) is str + + assert e1 == 0.0 + assert -0.001 < e1 < 0.001 + assert -0.001 <= e1 <= 0.001 + + assert e1.units == ha + + # Cannot convert to a non-existent unit + with pytest.raises(TypeError): + _ = e1.to("xxx") + + # or to a non-energy unit + with pytest.raises(TypeError): + _ = e1.to(deg) + + # but can convert to a different type of energy unit + e1_kcal = e1.to(kcalmol) + assert e1_kcal == 0.0 + + # Conversion is not in place + assert e1.units == ha + + e1 -= 0.1 + assert isinstance(e1, Energy) + assert e1 == -0.1 + + e1 *= 10 + assert np.isclose(e1, -1.0) + + # Should be able to add two energies + e3 = e1 + Energy(1) + assert e3 == 0.0 + + e_kcal = Energy(1 * Constants.ha_to_kcalmol, units=kcalmol) + e_ha = Energy(1.0) + + # Values have implicit type conversion, left precedence + assert np.isclose((e_ha + e_kcal), 2.0) + assert (e_ha + e_kcal).units == ha + + # So the other way should add in kcal mol-1 + assert (e_kcal + e_ha) > 600 + + # Comparisons are viable in different units + assert Energy(1 * Constants.ha_to_kcalmol, units=kcalmol) < Energy( + 2, units=ha + ) + assert Energy(1 * Constants.ha_to_kcalmol, units=kcalmol) > Energy( + 0.5, units=ha + ) + + # Math operations should not be in-place + e1 = Energy(0.1) + e2 = Energy(0.2) + + e3 = e1 + e2 + assert e3 == 0.3 and e1 == 0.1 + + assert Energy(1.0) == Energy(1.0) + assert Energy(1.0) != Energy(1.1) + + assert Energy(1.0, units=ha) == Energy( + 1.0 * Constants.ha_to_kcalmol, units=kcalmol + ) + + assert np.isclose( + Energy(1.0, units=kcalmol), + Energy(4.0, units=kjmol).to("kcal"), + atol=0.5, + ) + + assert np.isclose( + Energy(27, units=ev), Energy(1, units=ha).to(ev), atol=0.5 + ) + + assert (Energy(1.0) * 10.0) == 10 + assert (10.0 * Energy(1.0)) == 10 + + +def test_energy_equality(): + """Energies must be equal to within""" + + assert Energy(-151.552245224975, units="Ha") != Energy( + -151.551673511378, units="Ha" + ) + + # Allowable comparison of energies and floats, assuming Ha units + assert Energy(-151.552245224975, units="Ha") != -151.551673511378 + + +def test_enthalpy(): + assert Enthalpy(1.0) != PotentialEnergy(1.0) + assert PotentialEnergy(1.0) != Enthalpy(1.0) + + +def test_free_energy(): + assert FreeEnergy(1.0) != PotentialEnergy(1.0) + assert FreeEnergy(1.0) != Enthalpy(1.0) + assert PotentialEnergy(1.0) != FreeEnergy(1.0) + + +def test_distance(): + assert "dist" in repr(Distance(1.0)).lower() + assert all(w in repr(MWDistance(1.0)).lower() for w in ("dist", "mass")) + + # Bohrs are ~2 angstroms + assert np.isclose( + Distance(1.0, units=ang), Distance(2.0, units=a0).to(ang), atol=0.3 + ) + + assert Distance(1.0, units=ang) == Distance(0.1, units=nm) + assert Distance(1.0, units=ang) == Distance(100, units=pm) + assert Distance(1.0, units=ang) == Distance(1e-10, units=m) + + +def test_angle(): + assert "ang" in repr(Angle(1.0)).lower() + + assert Angle(np.pi, units=rad) == Angle(180.0, units=deg) + + +def test_energies(): + energies = Energies() + energies.append(Energy(1.0)) + energies.append(Energy(1.0)) + + # Should not append identical energies + assert len(energies) == 1 + + energies = Energies(Energy(1.0), FreeEnergy(0.1)) + + assert energies.last(FreeEnergy) == FreeEnergy(0.1) + + assert "free" in repr(FreeEnergy(0.0)).lower() + assert "enthalpy" in repr(Enthalpy(0.0)).lower() + + assert "cont" in repr(FreeEnergyCont(0.0)).lower() + assert "cont" in repr(EnthalpyCont(0.0)).lower() + + # Check that adding an energy that is already present moves it to the end + energies = Energies() + energies.append(Energy(1.0)) + energies.append(Energy(5.0)) + energies.append(Energy(1.0)) + + assert energies.last(Energy) == 1.0 + + +def test_freqs(): + # Negative frequencies are actually imaginary (accepted convention in QM + # codes) + assert Frequency(-1.0).is_imaginary + assert not Frequency(1.0).is_imaginary + assert "freq" in repr(Frequency(1.0)).lower() + + assert Frequency(-1.0) != Frequency(1.0) + assert Frequency(-1.0).real == Frequency(1.0) + + +def test_mass(): + one_amu = Mass(1.0, units="amu") + assert "mass" in repr(one_amu).lower() + + assert np.isclose(one_amu.to("kg"), 1e-17, atol=1e-17) + assert np.isclose(one_amu.to("me"), 1823, atol=1) + + +def test_contrib_guidelines(): + """If any of these tests fail please modify doc/dev/contributing.rst + to reflect any changes""" + + r = Distance(1.0) + assert repr(r) == "Distance(1.0 Å)" + assert repr(r.to("nm")) == "Distance(0.1 nm)" + assert repr(r.to("nanometer")) == "Distance(0.1 nm)" + assert r > Distance(9.0, units="pm") + + with pytest.raises(TypeError): + _ = r.to("eV") + + +def test_gradient_norm(): + assert repr(GradientRMS(0.1)) is not None + + +def test_to_wrong_type(): + from autode.values import _to + + # To function must have either a Value or a ValueArray + with pytest.raises(Exception): + _to("a", units="Å") + + class Tmp: + units = "X" + + with pytest.raises(Exception): + _to(Tmp(), units="Å") + + +def test_div_mul_generate_floats(): + e = PotentialEnergy(1.0) + assert isinstance(e / e, float) + assert isinstance(e // e, float) + + assert e // e == 1 + + # Note: this behaviour is not ideal. But it is better than having the wrong units + assert isinstance(e * e, float) + + +def test_operations_maintain_other_attrs(): + e = Energy(1, estimated=True, units="eV") + assert e.is_estimated and e.units == ev + + e *= 2 + assert e.is_estimated and e.units == ev + + e /= 2 + assert e.is_estimated and e.units == ev + + a = e * 2 + assert a.is_estimated and e.units == ev + + +def test_inplace_value_modification_raises(): + e = Energy(1, units="Ha") + with pytest.raises(ValueError): # floats are immutable + _to(e, units="eV", inplace=True) + + +def test_energy_no_units_has_valid_repr(): + energy = Energy(1.0, units=None) + assert repr(energy) is not None + + +def test_to_no_units(): + energy = Energy(1.0, units=None) + with pytest.raises(RuntimeError): + _ = energy.to("ha") + + +def test_temp_conversion(): + x = Temperature("273.15") + assert x.units.name == "kelvin" + + y = x.to("C") + assert np.isclose(y, 0.0) + assert np.isclose(y.to("K"), x) diff --git a/autodE/source/tests/test_values.py b/autodE/source/tests/test_values.py new file mode 100644 index 0000000000000000000000000000000000000000..15a742de532aab88f56955c3add53448d0324a63 --- /dev/null +++ b/autodE/source/tests/test_values.py @@ -0,0 +1,149 @@ +import os + +import numpy as np +import pytest + +from autode.units import ang, ha, ha_per_ang, ha_per_a0, ev +from autode.values import ( + ValueArray, + Gradient, + Coordinate, + Coordinates, + MomentOfInertia, + ForceConstant, + _to, +) + +here = os.path.dirname(os.path.abspath(__file__)) + + +class TmpValues(ValueArray): + implemented_units = [ha, ev] + + def __repr__(self): + return "" + + +def test_base_arr(): + tmp_values = TmpValues(np.arange(2)) + assert tmp_values.units is None + + tmp_values = TmpValues(np.arange(2), units=ha) + assert tmp_values.units == ha + + for item in (None, "a", 0, np.zeros(2)): + # These are not the same! != calls __ne__ + assert not tmp_values == item + assert tmp_values != item + + +def test_unit_retention(): + vals = TmpValues(np.array([0.1]), units=ev) + assert vals.units == ev + + # Initialising an array from something with units should not default to the + # default unit type (Hartrees for energies) + vals1 = TmpValues(vals) + assert vals1.units == ev + + +def test_coordinate(): + coord = Coordinate(0.0, 0.0, 0.0) + assert coord.units == ang + assert "coord" in repr(coord).lower() + + assert coord is not None + assert np.allclose(coord, np.zeros(3)) + + +def test_coordinates(): + arr = np.array([[0.0, 0.0, 0.0], [0.0, 0.0, 0.1]]) + coords = Coordinates(arr) + assert coords.units == ang + + # Coordinates should reshape into n_atoms x 3 + coords = Coordinates(arr.flatten()) + assert coords.shape == (2, 3) + + assert "coord" in repr(coords).lower() + + +def test_moi(): + moi = MomentOfInertia(np.zeros(shape=(3, 3))) + assert "i" in repr(moi).lower() + + +def test_gradients(): + # Default gradient units are Ha Å^-1 + gradients = Gradient(np.arange(3, dtype="f8")) + assert gradients.units == ha_per_ang + assert "grad" in repr(gradients).lower() + + gradients_ha_a0 = gradients.to(ha_per_a0) + + # Energy per bohr is smaller than per angstrom.. + assert all( + g1 - g2 <= 0 + for g1, g2 in zip(gradients_ha_a0.flatten(), gradients.flatten()) + ) + + +class Unit: + conversion = 1.0 + aliases = ["unit"] + + def lower(self) -> str: + return "unit" + + +class InvalidValue(float): + implemented_units = [Unit] + units = Unit() + + +def test_to_unsupported(): + with pytest.raises(ValueError): + _ = _to(InvalidValue(), Unit(), inplace=True) + + +def test_inplace_modification(): + x = Gradient([[1.0, 1.0, 1.0]], units="Ha / Å") + return_value = x.to_("eV / Å") + assert return_value is None + + assert not np.allclose(x, np.ones(shape=(1, 3))) + + +def test_copy_conversion(): + x = Gradient([[1.0, 1.0, 1.0]], units="Ha / Å") + y = x.to("eV / Å") + assert not np.allclose(np.asarray(x), np.asarray(y)) + assert np.allclose(x, np.ones(shape=(1, 3))) + + +def test_force_constant(): + fc = ForceConstant(0.1) + assert "force" in repr(fc).lower() + + # should be able to convert to Ha/a0^2 without any problems + _ = fc.to("Ha/a0^2") + + +def test_pickle(): + """Regression test for https://github.com/duartegroup/autodE/issues/221""" + import pickle + + x = Gradient([[1.0, 1.0, 1.0]], units=ha_per_ang) + pickled_x = pickle.dumps(x, pickle.HIGHEST_PROTOCOL) + unpickled_x = pickle.loads(pickled_x) + assert unpickled_x.units == ha_per_ang + assert unpickled_x == x + + +def test_load_old_mlptrain_npz(): + """Regression test for https://github.com/duartegroup/autodE/issues/372""" + import numpy as np + + npz_file = os.path.join(here, "data", "old_mlptrain.npz") + data = np.load(npz_file, allow_pickle=True) + assert data["F_true"] is not None diff --git a/autodE/source/tests/test_wrappers/__init__.py b/autodE/source/tests/test_wrappers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/autodE/source/tests/test_wrappers/data/g09.zip b/autodE/source/tests/test_wrappers/data/g09.zip new file mode 100644 index 0000000000000000000000000000000000000000..d4e7ff56feee31d7f6bf9b7b8dd58e28d23a681a --- /dev/null +++ b/autodE/source/tests/test_wrappers/data/g09.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aee753c309a2df55576806be9f8e56a55be300caf73b8eee8543df19410deb31 +size 529820 diff --git a/autodE/source/tests/test_wrappers/data/mopac.zip b/autodE/source/tests/test_wrappers/data/mopac.zip new file mode 100644 index 0000000000000000000000000000000000000000..322ffab328e68356a07d651545f4cab717927e70 --- /dev/null +++ b/autodE/source/tests/test_wrappers/data/mopac.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0d8fd67004eb3849d28f65bfabfbb4db30fa37c85b3d1c0f2031c90f39ff20b3 +size 34445 diff --git a/autodE/source/tests/test_wrappers/data/nwchem.zip b/autodE/source/tests/test_wrappers/data/nwchem.zip new file mode 100644 index 0000000000000000000000000000000000000000..b52de4d002c59c0ff775a1225536940a58ba560c --- /dev/null +++ b/autodE/source/tests/test_wrappers/data/nwchem.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:65ac8d731c71323947d8f1a0389b6d6becb9338ffe67339fb8fa8d773b97ac21 +size 217249 diff --git a/autodE/source/tests/test_wrappers/data/orca.zip b/autodE/source/tests/test_wrappers/data/orca.zip new file mode 100644 index 0000000000000000000000000000000000000000..04033146955f2e51b27cc64753c78ce3fcf98380 --- /dev/null +++ b/autodE/source/tests/test_wrappers/data/orca.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e9ac3e0c8b77d2cb601eb082cbcadcc6e4e32083db2c2f2ddba7538f50c8e77c +size 167786 diff --git a/autodE/source/tests/test_wrappers/data/qchem.zip b/autodE/source/tests/test_wrappers/data/qchem.zip new file mode 100644 index 0000000000000000000000000000000000000000..52d9660ab31016faaafbfd09ce52ac9e11164cda --- /dev/null +++ b/autodE/source/tests/test_wrappers/data/qchem.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7ca0b44d5dc3e97a125c4f454c9c826c6b75ee5e5fa576f2686566246edd2a23 +size 195544 diff --git a/autodE/source/tests/test_wrappers/data/xtb.zip b/autodE/source/tests/test_wrappers/data/xtb.zip new file mode 100644 index 0000000000000000000000000000000000000000..fb8f4b9eb4d30e8cf34d4eb71f9827a36ca9fa59 --- /dev/null +++ b/autodE/source/tests/test_wrappers/data/xtb.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fea7f066f3efa993cb53c38cf9c3ecc327f8e79ec75634663885fbd64a704548 +size 54791 diff --git a/autodE/source/tests/test_wrappers/test_gaussian.py b/autodE/source/tests/test_wrappers/test_gaussian.py new file mode 100644 index 0000000000000000000000000000000000000000..556f4b1d10117fe79242c95ba1e9c1424a6e1fd6 --- /dev/null +++ b/autodE/source/tests/test_wrappers/test_gaussian.py @@ -0,0 +1,452 @@ +import pytest +import os +import numpy as np +from autode.wrappers.G09 import ( + G09, + _print_custom_basis, + _get_keywords, + _n_ecp_elements, + _add_opt_option, +) +from autode.wrappers.G16 import G16 +from autode.calculations import Calculation, CalculationInput +from autode.constraints import Constraints +from autode.species.molecule import Molecule +from autode.wrappers import keywords as kwds +from autode.values import PotentialEnergy +from autode.wrappers.keywords.basis_sets import def2tzecp, def2tzvp +from autode.wrappers.keywords.functionals import pbe0 +from autode.wrappers.keywords.keywords import ( + OptKeywords, + SinglePointKeywords, + HessianKeywords, +) +from autode.exceptions import CalculationException +from autode.point_charges import PointCharge +from autode.atoms import Atom, Atoms +from .. import testutils + +here = os.path.dirname(os.path.abspath(__file__)) +g09_zip_path = os.path.join(here, "data", "g09.zip") +method = G09() + +opt_keywords = OptKeywords(["PBE1PBE/Def2SVP", "Opt"]) +optts_keywords = OptKeywords( + [ + "PBE1PBE/Def2SVP", + "Freq", + "Opt=(TS, CalcFC, NoEigenTest, " + "MaxCycles=100, MaxStep=10, NoTrustUpdate)", + ] +) + +sp_keywords = SinglePointKeywords(["PBE1PBE/Def2SVP"]) + + +def methane(): + return Molecule(name="methane", smiles="C") + + +def test_printing_ecp(): + tmp_file = open("tmp.com", "w") + tmp_mol = Molecule(smiles="[H][Pd][H]") + tmp_mol.constraints = Constraints(distance={}, cartesian=[]) + + keywords = kwds.OptKeywords(keyword_list=[def2tzecp]) + assert _n_ecp_elements(keywords, molecule=tmp_mol) == 1 + # Light elements should not default to ECPs + assert _n_ecp_elements(keywords, molecule=Molecule(smiles="O")) == 0 + # no ECP keywords -> no elements needing an ECP + assert ( + _n_ecp_elements(kwds.OptKeywords(keyword_list=[]), molecule=tmp_mol) + == 0 + ) + + calc_input = CalculationInput( + keywords, added_internals=None, point_charges=None + ) + + with pytest.raises(RuntimeError): + _print_custom_basis(tmp_file, molecule=tmp_mol, calc_input=calc_input) + + calc_input.keywords = kwds.OptKeywords( + keyword_list=[pbe0, def2tzvp, def2tzecp] + ) + _print_custom_basis(tmp_file, molecule=tmp_mol, calc_input=calc_input) + assert os.path.exists("basis.gbs") + tmp_file.close() + + os.remove("tmp.com") + os.remove("basis.gbs") + + # Should have GenECP in the keywords rather than the ECP or basis + # definitions + g09_kwds = _get_keywords(calc_input, molecule=tmp_mol) + assert not any(kwd.lower() == "def2tzvp" for kwd in g09_kwds) + + +def test_add_opt_option(): + keywds = ["Opt=Loose"] + _add_opt_option(keywds, "MaxCycles=10") + assert keywds[0].lower() == "opt=(loose, maxcycles=10)" + + +def test_input_print_max_opt(): + keywds = opt_keywords.copy() + keywds.max_opt_cycles = 10 + + str_keywords = _get_keywords(CalculationInput(keywds), molecule=methane()) + + # Should be only a single instance of the maxcycles declaration + assert sum("maxcycles=10" in kw.lower() for kw in str_keywords) == 1 + + +@testutils.work_in_zipped_dir(g09_zip_path) +def test_get_gradients(): + ester = Molecule( + name="ester", + atoms=[ + Atom("C", -1.82707, 0.08502, 0.12799), + Atom("C", -0.42971, 0.07495, -0.39721), + Atom("O", 0.47416, -0.05624, 0.58034), + Atom("C", 1.84921, -0.11372, 0.17588), + Atom("O", -0.11743, 0.16179, -1.57499), + Atom("H", -1.93163, 0.83516, 0.91779), + Atom("H", -2.52831, 0.29353, -0.68072), + Atom("H", -2.05617, -0.89219, 0.56728), + Atom("H", 2.41930, -0.21232, 1.09922), + Atom("H", 2.13056, 0.80382, -0.3467), + Atom("H", 2.01729, -0.97969, -0.4689), + ], + ) + + calc = Calculation( + name="ester", + molecule=ester, + method=method, + keywords=method.keywords.opt, + ) + calc.set_output_filename("ester_opt_g09.log") + + gradients = ester.gradient + assert gradients is not None + assert gradients.shape == (ester.n_atoms, 3) + + +@testutils.work_in_zipped_dir(g09_zip_path) +def test_gauss_opt_calc(): + methylchloride = Molecule( + name="CH3Cl", smiles="[H]C([H])(Cl)[H]", solvent_name="water" + ) + calc = Calculation( + name="opt", + molecule=methylchloride, + method=method, + keywords=opt_keywords, + ) + calc.run() + + assert os.path.exists("opt_g09.com") + assert os.path.exists("opt_g09.log") + assert len(methylchloride.atoms) == 5 + assert methylchloride.energy.to("Ha") == -499.729222331 + assert calc.output.exists + assert calc.output.file_lines is not None + assert methylchloride.imaginary_frequencies is None + + assert calc.input.filename == "opt_g09.com" + assert calc.output.filename == "opt_g09.log" + assert calc.terminated_normally + assert calc.optimiser.converged + + charges = methylchloride.partial_charges + assert len(charges) == methylchloride.n_atoms + + # Should be no very large atomic charges in this molecule + assert all(-1.0 < c < 1.0 for c in charges) + + gradients = methylchloride.gradient + assert len(gradients) == methylchloride.n_atoms + assert len(gradients[0]) == 3 + + # Should be no large forces for an optimised molecule + assert sum(gradients[0]) < 0.1 + + # Should have a small non-zero last energy change + print(calc.optimiser.last_energy_change) + assert calc.optimiser.last_energy_change == PotentialEnergy(1.127e-5, "Ha") + + +@testutils.work_in_zipped_dir(g09_zip_path) +def test_gauss_optts_calc(): + test_mol = Molecule(name="methane", smiles="C") + test_mol.atoms = Atoms( + [ + Atom("C", -0.022100, 0.003200, 0.016500), + Atom("H", -0.669000, 0.889400, -0.100900), + Atom("H", -0.377800, -0.857800, -0.588300), + Atom("H", 0.096400, -0.315100, 1.0638000), + Atom("H", 0.972500, 0.280300, -0.3911000), + ] + ) + test_mol.graph.add_active_edge(0, 1) + + calc = Calculation( + name="test_ts_reopt_optts", + molecule=test_mol, + method=method, + keywords=optts_keywords, + ) + calc.run() + assert calc.output.exists + + assert os.path.exists("test_ts_reopt_optts_g09.com") + + bond_added = False + for line in open("test_ts_reopt_optts_g09.com", "r"): + if "B" in line and len(line.split()) == 3: + bond_added = True + assert line.split()[0] == "B" + assert line.split()[1] == "1" + assert line.split()[2] == "2" + + assert bond_added + + test_mol.calc_thermo(calc=calc, ss="1atm", lfm_method="igm") + assert calc.terminated_normally + assert calc.optimiser.converged + assert test_mol.imaginary_frequencies is not None + + assert len(test_mol.imaginary_frequencies) == 1 + + assert -40.324 < test_mol.free_energy < -40.322 + assert -40.301 < test_mol.enthalpy < -40.298 + + +def test_bad_gauss_output(): + calc = Calculation( + name="no_output", + molecule=methane(), + method=method, + keywords=opt_keywords, + ) + calc.output_file_lines = [] + calc.rev_output_file_lines = [] + + with pytest.raises(CalculationException): + calc.set_output_filename("no_output") + + +@testutils.work_in_zipped_dir(g09_zip_path) +def test_fix_angle_error(): + os.chdir(os.path.join(here, "data", "g09")) + + mol = Molecule(smiles="CC/C=C/CO") + mol.name = "molecule" + + calc = Calculation( + name="angle_fail", molecule=mol, method=method, keywords=opt_keywords + ) + calc.run() + + assert os.path.exists("angle_fail_g09_cartesian.com") is True + assert os.path.exists("angle_fail_g09_internal.com") is True + assert calc.output.filename == "angle_fail_g09_internal.log" + assert calc.terminated_normally + + +@testutils.work_in_zipped_dir(g09_zip_path) +def test_constraints(): + a = methane() + a.constraints.distance = {(0, 1): 1.2} + calc = Calculation( + name="const_dist_opt", molecule=a, method=method, keywords=opt_keywords + ) + calc.run() + opt_atoms = a.atoms + + assert ( + 1.199 < np.linalg.norm(opt_atoms[0].coord - opt_atoms[1].coord) < 1.201 + ) + + b = methane() + b.constraints.cartesian = [0] + calc = Calculation( + name="const_cart_opt", molecule=b, method=method, keywords=opt_keywords + ) + calc.run() + opt_atoms = b.atoms + assert np.linalg.norm(methane().atoms[0].coord - opt_atoms[0].coord) < 1e-3 + + +@testutils.work_in_zipped_dir(g09_zip_path) +def test_single_atom_opt(): + mol = Molecule(smiles="[H]") + mol.name = "molecule" + + calc = Calculation( + name="H", molecule=mol, method=method, keywords=opt_keywords, n_cores=2 + ) + calc.generate_input() + assert os.path.exists("H_g09.com") + + input_file_lines = open("H_g09.com", "r").readlines() + + n_cores_set = False + for line in input_file_lines: + if "PBE" in line: + assert "Opt" not in line + if "%nprocshared=2" in line: + n_cores_set = True + + assert n_cores_set + + +@testutils.work_in_zipped_dir(g09_zip_path) +def test_point_charge_calc(): + # Methane single point using a point charge with a unit positive charge + # located at (10, 10, 10) + + mol = methane() + calc = Calculation( + name="methane_point_charge", + molecule=mol, + method=method, + keywords=sp_keywords, + point_charges=[PointCharge(charge=1.0, x=10.0, y=10.0, z=10.0)], + ) + calc.run() + + # Assert that the input file is in the expected configuration + for line in open("methane_point_charge_g09.com", "r"): + if "PBE" in line: + assert "Charge" in line + + if len(line.split()) == 4: + if not line.split()[0].isdigit(): + continue + + x, y, z, charge = line.split() + assert float(x) == 10.0 + assert float(y) == 10.0 + assert float(z) == 10.0 + assert float(charge) == 1.0 + + assert -40.428 < mol.energy < -40.427 + + # Gaussian needs x-matrix and nosymm in the input line to run optimisations + # with point charges.. + for opt_keyword in ["Opt", "Opt=Tight", "Opt=(Tight)"]: + calc = Calculation( + name="methane_point_charge_o", + molecule=methane(), + method=method, + keywords=OptKeywords(["PBE1PBE/Def2SVP", opt_keyword]), + point_charges=[PointCharge(charge=1.0, x=3.0, y=3.0, z=3.0)], + ) + calc.generate_input() + + for line in open("methane_point_charge_o_g09.com", "r").readlines(): + if "PBE" in line: + assert "charge" in line.lower() + assert "z-matrix" in line.lower() and "nosymm" in line.lower() + break + + +@testutils.work_in_zipped_dir(g09_zip_path) +def test_external_basis_set_file(): + """ + + Example calculation with a custom basis set and ECP + ----------------------------------------------- + # Opt M062X EmpiricalDispersion=GD3BJ genecp + + name of calc + + 0 1 + xyz coordinates + + @bs1.gbs + + """ + + # This test needs to not change the filename based on the input as the + # keywords depend on the current working directory, thus is not generally + # going to be the same + if os.getenv("AUTODE_FIXUNIQUE", True) != "False": + return + + custom = G16() + + basis_path = os.path.join(os.getcwd(), "bs1.gbs") + custom.keywords.set_opt_basis_set(basis_path) + assert custom.keywords.opt.basis_set.has_only_name + + custom.keywords.sp.basis = basis_path + + pd_cl2 = Molecule("pd_cl2.xyz") + pd_cl2.single_point(method=custom) + assert pd_cl2.energy is not None + # ensure the energy is in the right ball-park + assert np.abs(pd_cl2.energy - -1046.7287) < 1e-2 + + +@testutils.work_in_zipped_dir(g09_zip_path) +def test_xtb_optts(): + g09 = G09() + + kwd_list = [ + "External='xtb-gaussian'", + "Opt(TS, CalcFC, NoEigenTest, MaxCycles=100, MaxStep=10, " + "NoTrustUpdate, NoMicro)", + "IOp(3/5=30)", + ] + + orca_ts = Molecule( + atoms=[ + Atom("F", -5.15221, 4.39259, 0.10105), + Atom("Cl", -1.03103, 4.55239, -0.06066), + Atom("C", -3.15949, 4.47211, 0.02185), + Atom("H", -3.27697, 3.86557, -0.86787), + Atom("H", -3.20778, 3.99594, 0.99353), + Atom("H", -3.30829, 5.54289, -0.04740), + ], + charge=-1, + solvent_name="water", + ) + + calc = Calculation( + name="tmp", + molecule=orca_ts, + method=g09, + keywords=OptKeywords(kwd_list), + ) + calc.run() + + # Even though a Hessian is not requested it should be added + assert orca_ts.hessian is not None + assert np.isclose(orca_ts.energy.to("Ha"), -13.1297380, atol=1e-5) + + +@testutils.work_in_zipped_dir(g09_zip_path) +def test_hessian_extraction_from_alt_output_file(): + mol = Molecule( + atoms=[ + Atom("F", -8.22915200, 4.04133200, 0.18431300), + Atom("Cl", -0.50976200, 4.77416000, -0.11240000), + Atom("C", -2.37030200, 4.53188200, 0.00287900), + Atom("H", -2.64812000, 3.88176700, -0.81515200), + Atom("H", -2.56235600, 4.07947800, 0.96591700), + Atom("H", -2.81607800, 5.51287000, -0.08505800), + ], + charge=-1, + ) + + calc = Calculation( + name="tmp", + molecule=mol, + method=G09(), + keywords=HessianKeywords(), + ) + calc.set_output_filename("tmp_g09_hess_alt.log") + assert mol.hessian is not None diff --git a/autodE/source/tests/test_wrappers/test_keywords.py b/autodE/source/tests/test_wrappers/test_keywords.py new file mode 100644 index 0000000000000000000000000000000000000000..f00aac1ad23e0de7c0140a0a165ebf71c29ee6cf --- /dev/null +++ b/autodE/source/tests/test_wrappers/test_keywords.py @@ -0,0 +1,241 @@ +import pytest +from autode.wrappers.keywords.functionals import pbe +from autode.wrappers.keywords.dispersion import d3bj +from autode.wrappers.keywords.wf import hf +from autode.wrappers.keywords.basis_sets import def2tzvp, def2ecp +from autode.config import Config +from copy import deepcopy +from autode.wrappers.keywords import ( + Keywords, + KeywordsSet, + ECP, + Functional, + BasisSet, + DispersionCorrection, + ImplicitSolventType, + RI, + WFMethod, + MaxOptCycles, + GradientKeywords, + OptKeywords, + HessianKeywords, + SinglePointKeywords, +) + + +def test_keywords(): + keywords = OptKeywords(keyword_list=None) + assert keywords._list == [] + + assert isinstance(GradientKeywords(None), Keywords) + assert isinstance(OptKeywords(None), Keywords) + assert isinstance(SinglePointKeywords(None), Keywords) + + keywords = OptKeywords(keyword_list=["test"]) + assert "test" in str(keywords) + assert "test" in repr(keywords) + + # Should not add a keyword that's already there + keywords.append("test") + assert len(keywords._list) == 1 + + assert hasattr(keywords, "copy") + + # Should have reasonable names + assert "opt" in repr(OptKeywords(None)).lower() + assert "hess" in repr(HessianKeywords(None)).lower() + assert "grad" in repr(GradientKeywords(None)).lower() + assert "sp" in repr(SinglePointKeywords(None)).lower() + + assert "pbe" in repr(pbe).lower() + + keywords = OptKeywords([pbe, def2tzvp, d3bj]) + assert len(keywords) == 3 + assert keywords.bstring is not None + assert "pbe" in keywords.bstring.lower() + assert "def2" in keywords.bstring.lower() + assert "d3bj" in keywords.bstring.lower() + + # Keywords have a defined order + assert "pbe" in keywords[0].name.lower() + + assert "hf" in OptKeywords([hf, def2tzvp]).bstring.lower() + + +def test_wf_keywords_string(): + assert "hf" in OptKeywords([hf]).method_string.lower() + + +def test_set_keywordsset(): + kwset = deepcopy(Config.G09.keywords) + assert hasattr(kwset, "opt") + assert "keywords" in repr(kwset).lower() + assert kwset.low_sp is not None + assert kwset[0] is not None # Allow indexing + + kwset.set_opt_functional(pbe) + assert kwset.opt.functional.lower() == "pbe" + assert kwset.opt_ts.functional.lower() == "pbe" + assert kwset.hess.functional.lower() == "pbe" + assert kwset.grad.functional.lower() == "pbe" + assert kwset.sp.functional.lower() != "pbe" + + # Should now all be PBE functionals + kwset.set_functional(pbe) + assert kwset.sp.functional.lower() == "pbe" + assert kwset.low_sp.functional.lower() == "pbe" + + kwset.set_opt_basis_set(def2tzvp) + assert kwset.opt.basis_set.lower() == "def2-tzvp" + + # Should admit no dispersion correction + assert kwset.opt.dispersion is not None + kwset.set_dispersion(None) + assert kwset.opt.dispersion is None + assert kwset.sp.dispersion is None + + +def test_keyword_repr(): + assert "basis" in repr(BasisSet("pbe")).lower() + assert "disp" in repr(DispersionCorrection("d3")).lower() + assert "func" in repr(Functional("pbe")).lower() + assert "solv" in repr(ImplicitSolventType("cosmo")).lower() + assert "resolution" in repr(RI("ri")).lower() + assert "wavefunction" in repr(WFMethod("hf")).lower() + assert "effective" in repr(def2ecp).lower() + assert "max" in repr(MaxOptCycles(10)).lower() + + +def test_ecp(): + kwds_set = KeywordsSet() + assert kwds_set.opt.ecp is None + + ecp1 = ECP(name="tmp_ecp", min_atomic_number=10) + ecp2 = ECP(name="tmp_ecp", min_atomic_number=20) + + assert not ecp1 == ecp2 + assert not ecp1 == "tmp_ecp" + ecp2.min_atomic_number = 10 + assert ecp1 == ecp2 + + assert isinstance(def2ecp, ECP) + kwds_set = KeywordsSet(ecp=def2ecp) + + for kwds in kwds_set: + assert kwds.ecp is not None + assert isinstance(kwds.ecp, ECP) + assert kwds.ecp.min_atomic_number == 37 + + kwds_set.set_ecp(None) + for kwds in kwds_set: + assert kwds.ecp is None + + +def test_max_opt_cycles(): + with pytest.raises(ValueError): + _ = MaxOptCycles("a") + + kwds = OptKeywords() + assert kwds.max_opt_cycles is None + + kwds.append(MaxOptCycles(10)) + assert kwds.max_opt_cycles == MaxOptCycles(10) + + kwds.max_opt_cycles = 20 + assert kwds.max_opt_cycles == MaxOptCycles(20) + + kwds.max_opt_cycles = MaxOptCycles(30) + assert kwds.max_opt_cycles == MaxOptCycles(30) + + kwds.max_opt_cycles = None + assert kwds.max_opt_cycles is None + + with pytest.raises(ValueError): + kwds.max_opt_cycles = -1 + + +def test_type_init(): + keyword_set = Config.ORCA.keywords.copy() + + for opt_type in ("low_opt", "opt", "opt_ts"): + kwds = getattr(keyword_set, opt_type) + assert isinstance(kwds, OptKeywords) + + for opt_type in ("low_sp", "sp"): + kwds = getattr(keyword_set, opt_type) + assert kwds is None or isinstance(kwds, SinglePointKeywords) + + assert isinstance(keyword_set.hess, HessianKeywords) + assert isinstance(keyword_set.grad, GradientKeywords) + + +def test_type_inference(): + """Ensure that setting keywords with lists retains their type""" + + keyword_set = Config.ORCA.keywords.copy() + + keyword_set.low_opt = ["a"] + assert isinstance(keyword_set.low_opt, OptKeywords) + + keyword_set.low_opt = OptKeywords(["a", "different", "set"]) + assert isinstance(keyword_set.low_opt, OptKeywords) + + keyword_set.opt = ["a"] + assert isinstance(keyword_set.opt, OptKeywords) + + keyword_set.opt_ts = ["a"] + assert isinstance(keyword_set.opt_ts, OptKeywords) + + keyword_set.sp = ["a"] + assert isinstance(keyword_set.sp, SinglePointKeywords) + + keyword_set.low_sp = ["a"] + assert isinstance(keyword_set.low_sp, SinglePointKeywords) + keyword_set.low_sp = None + + keyword_set.grad = ["a"] + assert isinstance(keyword_set.grad, GradientKeywords) + + keyword_set.hess = ["a"] + assert isinstance(keyword_set.hess, HessianKeywords) + + +def test_keywords_contain(): + kwds = SinglePointKeywords(["PBE", "Opt"]) + + assert kwds.contain_any_of("pbe") + assert kwds.contain_any_of("pbe", "opt") + assert kwds.contain_any_of("opt") + + assert not kwds.contain_any_of("PBE0") + + +def test_functional_equality(): + assert Functional("PBE0") == Functional("PBE0") + assert Functional("PBE0") != 1 + + +def test_keyword_addition(): + a = OptKeywords("a") + b = OptKeywords("b") + + assert "b" in (a + b) + assert "b" in a + ["b"] + + with pytest.raises(ValueError): + _ = a + 2 + + assert isinstance(a + b, OptKeywords) + + +def test_keyword_setting_with_empty_keywords(): + keywords = SinglePointKeywords() + keywords.functional = None # Should not append to the list + assert len(keywords) == 0 + + +def test_setting_keyword_by_index(): + keywords = SinglePointKeywords(["a"]) + keywords[0] = "b" + assert len(keywords) == 1 + assert keywords[0] == "b" diff --git a/autodE/source/tests/test_wrappers/test_mopac.py b/autodE/source/tests/test_wrappers/test_mopac.py new file mode 100644 index 0000000000000000000000000000000000000000..0323261a3813943bd209fa6c29c9c64a092731d5 --- /dev/null +++ b/autodE/source/tests/test_wrappers/test_mopac.py @@ -0,0 +1,325 @@ +from autode.wrappers.MOPAC import MOPAC +from autode.wrappers.MOPAC import get_keywords, _get_atoms_linear_interp +from autode.exceptions import ( + CouldNotGetProperty, + UnsupportedCalculationInput, + CalculationException, +) +from autode.calculations import Calculation, CalculationInput +from autode.species.molecule import Molecule +from autode.solvent import ImplicitSolvent +from autode.atoms import Atom +from autode.constants import Constants +from autode.config import Config +from autode.values import PotentialEnergy +from autode.point_charges import PointCharge +from .. import testutils +import numpy as np +import os +import pytest + +here = os.path.dirname(os.path.abspath(__file__)) +method = MOPAC() + + +def mecl(): + return Molecule( + name="CH3Cl", smiles="[H]C([H])(Cl)[H]", solvent_name="water" + ) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "mopac.zip")) +def test_mopac_opt_calculation(): + mol = mecl() + calc = Calculation( + name="opt", + molecule=mol, + method=method, + keywords=Config.MOPAC.keywords.opt, + ) + calc.run() + + assert os.path.exists("opt_mopac.mop") is True + assert os.path.exists("opt_mopac.out") is True + assert mol.n_atoms == 5 + + # Actual energy in Hartrees + energy = Constants.eV_to_ha * -430.43191 + assert energy - 0.0001 < mol.energy < energy + 0.0001 + + assert calc.output.exists + assert calc.output.file_lines is not None + assert calc.input.filename == "opt_mopac.mop" + assert calc.output.filename == "opt_mopac.out" + assert calc.terminated_normally + assert calc.optimiser.converged + assert mol.gradient is None + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "mopac.zip")) +def test_mopac_with_pc(): + mol = mecl() + calc = Calculation( + name="opt_pc", + molecule=mol, + method=method, + keywords=Config.MOPAC.keywords.opt, + point_charges=[PointCharge(1, x=4, y=4, z=4)], + ) + calc.run() + + assert os.path.exists("opt_pc_mopac.mop") is True + assert os.path.exists("opt_pc_mopac.out") is True + assert len(mol.atoms) == 5 + + # Actual energy in Hartrees without any point charges + energy = Constants.eV_to_ha * -430.43191 + assert np.abs(mol.energy - energy) > 0.0001 + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "mopac.zip")) +def test_other_spin_states(): + o_singlet = Molecule(atoms=[Atom("O")], mult=1) + o_singlet.name = "molecule" + + calc = Calculation( + name="O_singlet", + molecule=o_singlet, + method=method, + keywords=Config.MOPAC.keywords.sp, + ) + calc.run() + + o_triplet = Molecule(atoms=[Atom("O")], mult=3) + o_triplet.name = "molecule" + + calc = Calculation( + name="O_triplet", + molecule=o_triplet, + method=method, + keywords=Config.MOPAC.keywords.sp, + ) + calc.run() + + assert o_triplet.energy < o_singlet.energy + + h_doublet = Molecule(atoms=[Atom("H")], mult=2) + h_doublet.name = "molecule" + + calc = Calculation( + name="h", + molecule=h_doublet, + method=method, + keywords=Config.MOPAC.keywords.sp, + ) + calc.run() + + # Open shell doublet should work + assert h_doublet.energy is not None + + h_quin = Molecule(atoms=[Atom("H")], mult=5) + h_quin.name = "molecule" + + with pytest.raises(CalculationException): + calc = Calculation( + name="h", + molecule=h_quin, + method=method, + keywords=Config.MOPAC.keywords.sp, + ) + calc.run() + + os.chdir(here) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "mopac.zip")) +def test_bad_geometry(): + # Calculation with the wrong spin state should fail + calc = Calculation( + name="h2_overlap_opt", + molecule=Molecule(atoms=[Atom("H"), Atom("H")]), + method=method, + keywords=Config.MOPAC.keywords.opt, + ) + + with pytest.raises(Exception): + # cannot even get the energy from the output file + calc.set_output_filename("h2_overlap_opt_mopac.out") + + assert not method.optimiser_from(calc).converged + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "mopac.zip")) +def test_constrained_opt(): + methane = Molecule(name="methane", smiles="C") + + calc = Calculation( + name="methane_opt", + molecule=methane, + method=method, + keywords=Config.MOPAC.keywords.opt, + ) + calc.run() + opt_energy = methane.energy + + # Constrained optimisation with a C–H distance of 1.2 Å + # (carbon is the first atom in the file) + constrained_methane = methane.copy() + methane.constraints.distance = {(0, 1): 1.2} + + const = Calculation( + name="methane_const", + molecule=constrained_methane, + method=method, + keywords=Config.MOPAC.keywords.opt, + ) + const.run() + + assert methane.energy < constrained_methane.energy + assert methane.hessian is None + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "mopac.zip")) +def test_grad(): + h2 = Molecule(name="h2", atoms=[Atom("H"), Atom("H", x=0.5)]) + + grad_calc = Calculation( + name="h2_grad", + molecule=h2, + method=method, + keywords=Config.MOPAC.keywords.grad, + ) + grad_calc.run() + assert h2.energy is not None + + gradients = h2.gradient + assert gradients.shape == (2, 3) + + delta_r = 1e-5 + h2_disp = Molecule( + name="h2_disp", atoms=[Atom("H"), Atom("H", x=0.5 + delta_r)] + ) + h2_disp.single_point(method) + + delta_energy = h2_disp.energy - h2.energy + grad = delta_energy / delta_r # Ha A^-1 + + # Difference between the absolute and finite difference approximation + assert np.abs(gradients[1, 0] - grad) < 1e-1 + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "mopac.zip")) +def test_broken_grad(): + h2 = Molecule(name="h2", atoms=[Atom("H"), Atom("H", x=0.5)]) + grad_calc_broken = Calculation( + name="h2_grad", + molecule=h2, + method=method, + keywords=Config.MOPAC.keywords.grad, + ) + + with pytest.raises(CouldNotGetProperty): + grad_calc_broken.set_output_filename("h2_grad_broken.out") + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "mopac.zip")) +def test_new_energy(): + h2o = Molecule(smiles="O") + calc = Calculation( + name="H2O", + molecule=h2o, + method=method, + keywords=Config.MOPAC.keywords.grad, + ) + calc.set_output_filename("H2O_mopac_new.out") + + assert h2o.energy == PotentialEnergy(-322.5968, units="eV") + + +def test_termination_short(): + calc = Calculation( + name="test", + molecule=mecl(), + method=method, + keywords=Config.MOPAC.keywords.sp, + ) + + calc.output.filename = "test.out" + with open(calc.output.filename, "w") as test_output: + print("JOB ENDED NORMALLY", "another line", sep="\n", file=test_output) + + assert calc.terminated_normally + os.remove(calc.output.filename) + + +def test_mopac_keywords(): + calc_input = CalculationInput( + keywords=Config.MOPAC.keywords.sp, + added_internals=None, + point_charges=None, + ) + + keywords = get_keywords(calc_input=calc_input, molecule=mecl()) + assert any("1scf" == kw.lower() for kw in keywords) + + calc_input.keywords = Config.MOPAC.keywords.grad + keywords = get_keywords(calc_input=calc_input, molecule=mecl()) + assert any("grad" == kw.lower() for kw in keywords) + + h = Molecule(name="H", smiles="[H]") + assert h.mult == 2 + + keywords = get_keywords(calc_input=calc_input, molecule=h) + assert any("doublet" == kw.lower() for kw in keywords) + + +def test_get_version_no_output(): + calc = Calculation( + name="test", + molecule=mecl(), + method=method, + keywords=method.keywords.sp, + ) + calc.output.filename = "test.out" + + with open(calc.output.filename, "w") as test_output: + print("Error 1", "Error 2", sep="\n", file=test_output) + + assert not calc.terminated_normally + assert method.version_in(calc) == "???" + + os.remove(calc.output.filename) + + +def test_mopac_solvent_no_dielectric(): + mol = mecl() + mol.solvent = ImplicitSolvent("X", smiles="X", aliases=["X"], mopac="X") + + calc = Calculation( + "tmp", molecule=mol, method=method, keywords=method.keywords.sp + ) + + # Cannot generate an input if the solvent does not have a defined + # dielectric constant in the dictionary + with pytest.raises(UnsupportedCalculationInput): + calc.generate_input() + + if os.path.exists("tmp_mopac.mop"): + os.remove("tmp_mopac.mop") + + +def test_shifted_atoms(): + atoms = [Atom("H", 0.0, 0.0, 0.0), Atom("H", 0.0, 0.0, 2.0)] + + new_atoms = _get_atoms_linear_interp( + atoms, bonds=[(0, 1)], final_distances=[1.0] + ) + + # Linear interpolation of the coordinates should move the atom either + # end of the bond half way + assert ( + np.linalg.norm(new_atoms[0].coord - np.array([0.0, 0.0, 0.5])) < 1e-6 + ) + assert ( + np.linalg.norm(new_atoms[1].coord - np.array([0.0, 0.0, 1.5])) < 1e-6 + ) diff --git a/autodE/source/tests/test_wrappers/test_nwchem.py b/autodE/source/tests/test_wrappers/test_nwchem.py new file mode 100644 index 0000000000000000000000000000000000000000..cb44b1fb7715d954d18466bb719d6b24ae5e3f3f --- /dev/null +++ b/autodE/source/tests/test_wrappers/test_nwchem.py @@ -0,0 +1,264 @@ +import numpy as np +import pytest +import os + +from autode.wrappers.NWChem import NWChem, ecp_block +from autode.point_charges import PointCharge +from autode.calculations import Calculation +from autode.exceptions import UnsupportedCalculationInput, CalculationException +from autode.species.molecule import Molecule +from autode.wrappers.keywords import OptKeywords, SinglePointKeywords +from autode.wrappers.keywords.basis_sets import def2svp +from autode.wrappers.keywords.wf import hf +from autode.wrappers.keywords.functionals import pbe0 +from autode.config import Config +from autode.atoms import Atom +from autode.utils import work_in_tmp_dir +from .. import testutils + + +here = os.path.dirname(os.path.abspath(__file__)) +method = NWChem() +method.path = here # spoof install + +opt_keywords = OptKeywords( + [ + "basis\n * library Def2-SVP\nend", + "dft\n xc xpbe96 cpbe96\nend", + "task dft gradient", + ] +) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "nwchem.zip")) +def test_opt_calc(): + test_mol = Molecule(name="methane", smiles="C") + + calc = Calculation( + name="opt", molecule=test_mol, method=NWChem(), keywords=opt_keywords + ) + calc.run() + + assert len(test_mol.atoms) == 5 + assert type(test_mol.atoms[0]) is Atom + assert -40.4165 < test_mol.energy < -40.4164 + assert calc.terminated_normally + assert calc.optimiser.converged + + # No Hessian is computed for an optimisation calculation + assert test_mol.hessian is None + + # Optimisation should result in small gradients + gradients = test_mol.gradient + assert len(gradients) == 5 + assert all(-0.1 < np.linalg.norm(g) < 0.1 for g in gradients) + + +def test_opt_single_atom(): + h = Molecule(name="H", smiles="[H]") + calc = Calculation( + name="opt_h", molecule=h, method=method, keywords=opt_keywords + ) + calc.generate_input() + + # Can't do an optimisation of a hydrogen atom.. + assert os.path.exists("opt_h_nwchem.nw") + input_lines = open("opt_h_nwchem.nw", "r").readlines() + assert "opt" not in [keyword.lower() for keyword in input_lines[0].split()] + + os.remove("opt_h_nwchem.nw") + + +def test_exception_wf_solvent_calculation(): + solvated_mol = Molecule(name="methane", smiles="C", solvent_name="water") + + calc = Calculation( + name="opt", + molecule=solvated_mol, + method=method, + keywords=SinglePointKeywords([hf, def2svp]), + ) + + # Cannot have solvent with a non-DFT calculation(?) + with pytest.raises(CalculationException): + calc.generate_input() + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "nwchem.zip")) +def test_ecp_calc(): + # Should have no ECP block for molecule with only light elements + water_ecp_block = ecp_block( + Molecule(smiles="O"), keywords=method.keywords.sp + ) + assert water_ecp_block == "" + + # Should have no ECP block if the keywords do not define an ECP + pd_ecp_block = ecp_block(Molecule(smiles="[Pd]"), keywords=OptKeywords([])) + assert pd_ecp_block == "" + + pdh2 = Molecule(smiles="[H][Pd][H]", name="H2Pd") + pdh2.single_point(method=method) + + assert os.path.exists("H2Pd_sp_nwchem.nw") + input_lines = open("H2Pd_sp_nwchem.nw", "r").readlines() + assert any("ecp" in line for line in input_lines) + + assert pdh2.energy is not None + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "nwchem.zip")) +def test_hessian_extract_ts(): + ts = Molecule( + name="ts", + atoms=[ + Atom("F", 0.00000, 0.00000, 2.50357), + Atom("Cl", -0.00000, 0.00000, -1.62454), + Atom("C", 0.00000, 0.00000, 0.50698), + Atom("H", 1.05017, 0.24818, 0.60979), + Atom("H", -0.74001, 0.78538, 0.60979), + Atom("H", -0.31016, -1.03356, 0.60979), + ], + charge=-1, + ) + + calc = Calculation( + name="sn2_hess", + molecule=ts, + keywords=method.keywords.hess, + method=method, + ) + calc.set_output_filename("sn2_hess_nwchem.out") + + assert ts.hessian is not None + assert ts.hessian.shape == (3 * ts.n_atoms, 3 * ts.n_atoms) + + assert ts.gradient is not None + assert np.isclose(ts.gradient[-1][-1], -0.000588 / 0.529177) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "nwchem.zip")) +def test_hessian_extract_butane(): + Config.freq_scale_factor = 1.0 + + butane = Molecule("butane.xyz") + calc = Calculation( + name="butane", + molecule=butane, + keywords=method.keywords.hess, + method=method, + ) + calc.set_output_filename("butane_hess_nwchem.out") + + hess = butane.hessian + assert hess is not None + + # bottom right corner element should be positive + assert hess[-1, -1] > 0 + assert np.isclose(hess.frequencies[0].to("cm-1"), -2385.13, atol=3.0) + + assert np.isclose(hess.frequencies[-1].to("cm-1"), 3500.27, atol=3.0) + + calc = Calculation( + name="butane", + molecule=Molecule("butane.xyz"), + keywords=method.keywords.hess, + method=method, + ) + + with pytest.raises(CalculationException): + calc.set_output_filename("broken_hessian.out") + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "nwchem.zip")) +def test_hf_calculation(): + h2o = Molecule(smiles="O", name="H2O") + hf_kwds = [def2svp, "task scf"] + + h2o.single_point(method=method, keywords=hf_kwds) + + assert h2o.energy is not None + + #  Solvation is unavalible with HF in v <7.0.2 + h2o_in_water = Molecule(smiles="O", name="H2O_solv", solvent_name="water") + + with pytest.raises(CalculationException): + h2o_in_water.single_point(method=method, keywords=hf_kwds) + + # Open-shell calculations should be okay + + h = Molecule(smiles="[H]", name="H") + h.single_point(method=method, keywords=hf_kwds) + + assert np.isclose(h.energy, -0.5, atol=0.001) + + # Should also support other arguments in the SCF block + hf_kwds = [def2svp, "scf\n maxiter 100\nend", "task scf"] + h.single_point(method=method, keywords=hf_kwds) + + assert h.energy is not None + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "nwchem.zip")) +def test_point_charge_calculation(): + h = Molecule(smiles="[H]") + + calc = Calculation( + name="h", + molecule=h, + method=method, + keywords=SinglePointKeywords([def2svp, "task scf"]), + point_charges=[PointCharge(1.0, 0.0, 0.0, 1.0)], + ) + calc.run() + + assert h.energy is not None + + # H atom energy with a point charge should be different from the + # isolated atoms HF energy + assert not np.isclose(h.energy.to("Ha"), -0.5, atol=0.001) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "nwchem.zip")) +def test_charge_extract(): + h2o = Molecule(smiles="O") + calc = Calculation( + name="tmp", molecule=h2o, keywords=method.keywords.sp, method=method + ) + calc.set_output_filename("H2O_sp_nwchem.out") + + assert h2o.atomic_symbols == ["O", "H", "H"] + assert np.allclose(h2o.partial_charges, [-0.801244, 0.397696, 0.403548]) + + +def test_no_driver_in_generated_opt_input(): + opt_str = ( + "driver\n" + " gmax 0.0003\n" + " grms 0.0001\n" + " xmax 0.004\n" + " xrms 0.002\n" + " eprec 0.000005\n" + "end" + ) + + calc = Calculation( + name="tmp", + molecule=Molecule(smiles="O"), + keywords=OptKeywords([pbe0, def2svp, opt_str]), + method=method, + ) + + with pytest.raises(UnsupportedCalculationInput): + calc.generate_input() + + +@work_in_tmp_dir() +def test_single_atom_optimisation_input_file_does_not_include_opt(): + h_atom = Molecule(smiles="[H]") + + calc = Calculation( + name="tmp", molecule=h_atom, method=NWChem(), keywords=opt_keywords + ) + calc.generate_input() + + assert "opt" not in open(calc.input.filename, "r").read() diff --git a/autodE/source/tests/test_wrappers/test_orca.py b/autodE/source/tests/test_wrappers/test_orca.py new file mode 100644 index 0000000000000000000000000000000000000000..59b5af4c85c4ff9386863cb9dab953d448c896e4 --- /dev/null +++ b/autodE/source/tests/test_wrappers/test_orca.py @@ -0,0 +1,439 @@ +import autode.exceptions as ex +from autode.calculations import CalculationInput +from autode.atoms import Atom +from autode.constants import Constants +from autode.calculations import Calculation +from autode.species.molecule import Molecule +from autode.point_charges import PointCharge +from autode.input_output import xyz_file_to_atoms +from autode.wrappers.keywords import ( + SinglePointKeywords, + OptKeywords, + HessianKeywords, +) +from autode.wrappers.keywords import Functional, WFMethod, BasisSet +from autode.wrappers.keywords import cpcm +from autode.transition_states.transition_state import TransitionState +from autode.transition_states.ts_guess import TSguess +from autode.wrappers.ORCA import ( + ORCA, + ORCAOptimiser, + print_cartesian_constraints, + print_point_charges, +) +from autode import utils +from .. import testutils +import numpy as np +import pytest + +import os + +here = os.path.dirname(os.path.abspath(__file__)) +test_mol = Molecule(name="methane", smiles="C") +method = ORCA() + +sp_keywords = SinglePointKeywords(["PBE", "def2-SVP"]) +opt_keywords = OptKeywords(["Opt", "PBE", "def2-SVP"]) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "orca.zip")) +def test_orca_opt_calculation(): + methylchloride = Molecule( + name="CH3Cl", smiles="[H]C([H])(Cl)[H]", solvent_name="water" + ) + + calc = Calculation( + name="opt", + molecule=methylchloride, + method=method, + keywords=opt_keywords, + ) + calc.run() + + assert os.path.exists("opt_orca.inp") is True + assert os.path.exists("opt_orca.out") is True + assert len(methylchloride.atoms) == 5 + assert -499.735 < methylchloride.energy < -499.730 + assert calc.output.exists + assert calc.output.file_lines is not None + assert calc.input.filename == "opt_orca.inp" + assert calc.output.filename == "opt_orca.out" + assert calc.terminated_normally + + assert calc.optimiser.converged + + # Should have a partial atomic charge for every atom + charges = methylchloride.partial_charges + assert charges == [-0.006954, -0.147352, 0.052983, 0.052943, 0.053457] + + calc = Calculation( + name="opt", + molecule=methylchloride, + method=method, + keywords=opt_keywords, + ) + + # If the calculation is not run with calc.run() then there should be no + # input and the calc should raise that there is no input + with pytest.raises(ex.NoInputError): + f = utils.hashable("_execute_external", calc._executor) + f() + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "orca.zip")) +def test_orca_optts_calculation(): + ts = TransitionState.from_species( + Molecule("test_ts_reopt_optts_orca.xyz", charge=-1) + ) + ts.graph.add_active_edge(0, 1) + + optts_str = ( + "\n%geom\n" + "Calc_Hess true\n" + "Recalc_Hess 40\n" + "Trust 0.2\n" + "MaxIter 100\nend" + ) + + calc = Calculation( + name="test_ts_reopt_optts", + molecule=ts, + method=method, + keywords=opt_keywords + [optts_str], + ) + calc.run() + + ts.calc_thermo(calc=calc, ss="1atm", sn=1) + + assert os.path.exists("test_ts_reopt_optts_orca.inp") + + assert ts.normal_mode(mode_number=6) is not None + assert calc.terminated_normally + assert calc.optimiser.converged + assert len(ts.imaginary_frequencies) == 1 + + # Gradients should be an n_atom x 3 array + gradients = ts.gradient + assert gradients.shape == (ts.n_atoms, 3) + + assert -599.437 < ts.enthalpy < -599.436 + assert -599.469 < ts.free_energy < -599.468 + + +def test_bad_orca_output(): + calc = Calculation( + name="no_output", + molecule=test_mol, + method=method, + keywords=opt_keywords, + ) + + with pytest.raises(ex.CouldNotGetProperty): + calc.set_output_filename("no_output") + + calc.output_file_lines = None + assert calc.terminated_normally is False + + +def test_solvation(): + methane = Molecule( + name="solvated_methane", smiles="C", solvent_name="water" + ) + + with pytest.raises(ex.UnsupportedCalculationInput): + # Should raise on unsupported calculation type + method.implicit_solvation_type = "xxx" + calc = Calculation( + name="broken_solvation", + molecule=methane, + method=method, + keywords=sp_keywords, + ) + calc.run() + + method.implicit_solvation_type = "CPCM" + calc = Calculation( + name="methane_cpcm", + molecule=methane, + method=method, + keywords=sp_keywords, + ) + calc.generate_input() + + assert any( + "cpcm" in line.lower() for line in open("methane_cpcm_orca.inp", "r") + ) + os.remove("methane_cpcm_orca.inp") + + method.implicit_solvation_type = "SMD" + calc = Calculation( + name="methane_smd", + molecule=methane, + method=method, + keywords=sp_keywords, + ) + calc.generate_input() + + assert any( + "smd" in line.lower() for line in open("methane_smd_orca.inp", "r") + ) + os.remove("methane_smd_orca.inp") + + +def test_vdw_solvent_not_present(): + mol = Molecule(name="mol", smiles="C", solvent_name="2-butanol") + + orca = ORCA() + orca.implicit_solvation_type = cpcm + + calc = Calculation( + name="tmp", molecule=mol, method=orca, keywords=sp_keywords + ) + + # Cannot use gaussian charges for 2-butanol + with pytest.raises(ex.CalculationException): + calc.generate_input() + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "orca.zip")) +def test_gradients(): + h2 = Molecule(name="h2", atoms=[Atom("H"), Atom("H", x=1.0)]) + calc = Calculation( + name="h2_grad", + molecule=h2, + method=method, + keywords=method.keywords.grad, + ) + calc.run() + + delta_r = 1e-8 + + # Energy of a finite difference approximation + h2_disp = Molecule( + name="h2_disp", atoms=[Atom("H"), Atom("H", x=1.0 + delta_r)] + ) + calc = Calculation( + name="h2_disp", + molecule=h2_disp, + method=method, + keywords=method.keywords.grad, + ) + calc.run() + + delta_energy = h2_disp.energy - h2.energy # Ha + grad = delta_energy / delta_r # Ha A^-1 + + calc = Calculation( + name="h2_grad", + molecule=h2, + method=method, + keywords=method.keywords.grad, + ) + + calc.run() + + diff = h2.gradient[1, 0] - grad # Ha A^-1 + + # Difference between the absolute and finite difference approximation + assert np.abs(diff) < 1e-3 + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "orca.zip")) +def test_mp2_numerical_gradients(): + mol = Molecule("tmp_orca.xyz", charge=-1) + calc = Calculation( + name="tmp", + molecule=mol, + method=method, + keywords=method.keywords.grad, + ) + calc.set_output_filename(filename="tmp_orca.out") + + gradients = mol.gradient + assert len(gradients) == 6 + expected = ( + np.array([-0.00971201, -0.00773534, -0.02473580]) / Constants.a0_to_ang + ) + assert np.linalg.norm(expected - gradients[0]) < 1e-6 + + # Test for different printing with numerical.. + calc.set_output_filename(filename="numerical_orca.out") + assert calc.output.filename == "numerical_orca.out" + + gradients = mol.gradient + assert len(gradients) == 6 + expected = ( + np.array([0.012397372, 0.071726232, -0.070942743]) + / Constants.a0_to_ang + ) + assert np.linalg.norm(expected - gradients[0]) < 1e-6 + + +@utils.work_in_tmp_dir(filenames_to_copy=[], kept_file_exts=[]) +def test_keyword_setting(): + orca = ORCA() + kwds = orca.keywords.sp + kwds.functional = "B3LYP" + + # Setter should generate a Functional from the keyword string + assert isinstance(kwds.functional, Functional) + + calc = Calculation( + name="tmp", molecule=test_mol.copy(), method=orca, keywords=kwds + ) + calc.generate_input() + assert calc.input.exists + + # B3LYP should now be in the in input + inp_lines = open(calc.input.filename, "r").readlines() + assert any("B3LYP" in line for line in inp_lines) + + # With a keyword without ORCA defined then raise an exception + with pytest.raises(ex.UnsupportedCalculationInput): + orca.keywords.sp.functional = Functional(name="B3LYP", g09="B3LYP") + calc = Calculation( + name="tmp", + molecule=test_mol.copy(), + method=orca, + keywords=orca.keywords.sp, + ) + calc.generate_input() + + # Without a default wavefunction method defined in the single point method + # we can't set keywords.wf + with pytest.raises(ValueError): + orca.keywords.sp.wf_method = "HF" + + # but if we have a WF method in the keywords we should be able to set it + orca.keywords.sp = SinglePointKeywords( + [WFMethod("MP2"), BasisSet("def2-TZVP")] + ) + + orca.keywords.sp.wf_method = "HF" + assert orca.keywords.sp.wf_method == "HF" + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "orca.zip")) +def test_hessian_extraction(): + h2o = Molecule(smiles="O") + calc = Calculation( + name="tmp", + molecule=h2o, + method=method, + keywords=method.keywords.hess, + ) + + with open("H2O_hess_orca.xyz", "w") as xyz_file: + print( + "3\n", + "O -0.001100 0.363100 -0.000000", + "H -0.825000 -0.181900 -0.000000", + "H 0.826100 -0.181200 0.000000", + sep="\n", + file=xyz_file, + ) + + calc.set_output_filename("H2O_hess_orca.out") + + hessian = h2o.hessian + assert hessian.shape == (9, 9) + # should not have any very large values + assert np.sum(np.abs(hessian)) < 100 + + with pytest.raises(ex.CouldNotGetProperty): + calc.set_output_filename(filename="no_file.out") + + with pytest.raises(ex.CouldNotGetProperty): + calc.set_output_filename(filename="H2O_hess_broken.out") + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "orca.zip")) +def test_charges_from_v5_output_file(): + water = Molecule(smiles="O") + calc = Calculation( + name="h2_grad", + molecule=water, + method=method, + keywords=method.keywords.sp, + ) + calc.set_output_filename("h2o_orca_v5_charges.out") + assert calc.output.exists + # q_O q_H q_H + assert water.partial_charges == [-0.313189, 0.156594, 0.156594] + + +def test_unsupported_freq_scaling(): + kwds = HessianKeywords( + ["Freq", "PBE0", "def2-SVP", "%freq\nscalfreq 0.95\nend"] + ) + + calc = Calculation( + name="opt", molecule=test_mol, method=method, keywords=kwds + ) + + with pytest.raises(ex.UnsupportedCalculationInput): + calc.generate_input() + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "orca.zip")) +def test_orca_optimiser_from_output_file(): + optimiser = ORCAOptimiser(output_lines=[]) + assert not optimiser.converged + assert not np.isfinite(optimiser.last_energy_change) + + optimiser = ORCAOptimiser( + output_lines=open("opt_orca.out", "r").readlines() + ) + assert optimiser.converged + assert np.isclose( + optimiser.last_energy_change.to("Ha"), + -499.734431042133 - -499.734431061148, + ) + + +@utils.work_in_tmp_dir() +def test_cartesian_constraints_are_printed(): + idxs = [0, 1] + with open("tmp.inp", "w") as inp_file: + mol = Molecule(smiles="O") + mol.constraints.cartesian = idxs + print_cartesian_constraints(inp_file, mol) + + lines = "".join(open("tmp.inp", "r").readlines()) + for i in idxs: + assert "{ C " + str(i) + " C }" in lines + + +@utils.work_in_tmp_dir() +def test_point_charges_are_printed(): + calc_input = CalculationInput( + keywords=SinglePointKeywords(), point_charges=[PointCharge(0.1)] + ) + calc_input.filename = "tmp.inp" + + with open(calc_input.filename, "w") as inp_file: + print_point_charges(inp_file=inp_file, calc_input=calc_input) + + # should print a separate point charge file + assert os.path.exists("tmp.pc") + + +@utils.work_in_tmp_dir() +def test_getting_version_from_blank_output(): + calc = Calculation( + name="tmp", + molecule=Molecule(smiles="O"), + method=method, + keywords=method.keywords.sp, + ) + + filename = "tmp_orca.out" + with open(filename, "w") as out_file: + print("some\ninvalid\noutput", file=out_file) + + calc._executor.output.filename = filename + assert calc.method.version_in(calc) is not None + + # also ensure that this doesn't correspond to a normal termination + assert not calc.terminated_normally + assert not calc.method.terminated_normally_in(calc) diff --git a/autodE/source/tests/test_wrappers/test_qchem.py b/autodE/source/tests/test_wrappers/test_qchem.py new file mode 100644 index 0000000000000000000000000000000000000000..5138a87a6ed72c16ca26713177f910509aa94e53 --- /dev/null +++ b/autodE/source/tests/test_wrappers/test_qchem.py @@ -0,0 +1,568 @@ +import os +import pytest +import numpy as np +from autode.point_charges import PointCharge +from autode.wrappers.QChem import QChem +from autode.calculations import Calculation +from autode.values import Allocation +from autode.atoms import Atom +from autode.config import Config +from autode.wrappers.keywords import cpcm, pbe0, def2svp +from autode.species.molecule import Molecule +from autode.wrappers.keywords import SinglePointKeywords, OptKeywords +from autode.utils import work_in_tmp_dir, temporary_config +from autode.exceptions import CalculationException +from ..testutils import work_in_zipped_dir + +here = os.path.dirname(os.path.abspath(__file__)) +qchem_data_zip_path = os.path.join(here, "data", "qchem.zip") + +method = QChem() + + +def _blank_calc(name="test"): + """Blank calculation of a single H atom""" + + calc = Calculation( + name=name, + molecule=Molecule(atoms=[Atom("H")], mult=2), + method=method, + keywords=SinglePointKeywords(), + n_cores=Config.n_cores, + ) + + return calc + + +def _completed_thf_calc(): + calc = _blank_calc() + calc.set_output_filename("smd_thf.out") + + assert calc.output.exists + assert len(calc.output.file_lines) > 0 + + return calc + + +def _custom_output_calc(*lines): + calc = _blank_calc() + with open("tmp.out", "w") as out_file: + print(*lines, sep="\n", file=out_file) + + calc.output.filename = "tmp.out" + assert calc.output.exists + + return calc + + +def _broken_output_calc(): + return _custom_output_calc("a", "broken", "output", "file") + + +def _broken_output_calc2(): + return _custom_output_calc("broken", "Total energy") + + +def test_base_method(): + assert "qchem" in repr(method).lower() + + # TODO: Implement, if it's useful + with pytest.raises(Exception): + _ = method.partial_charges_from(_blank_calc()) + + calc = _blank_calc() + calc.input.point_charges = [PointCharge(0.1, x=0, y=0, z=1)] + + # TODO: Implement point charges within the surroundings of a molecule + with pytest.raises(NotImplementedError): + calc.generate_input() + + +def test_in_out_name(): + calc = _blank_calc(name="test")._executor + assert method.input_filename_for(calc) == "test_qchem.in" + assert method.output_filename_for(calc) == "test_qchem.out" + + +@work_in_zipped_dir(qchem_data_zip_path) +def test_version_extract(): + # Version extraction from a blank calculation should not raise an error + assert isinstance(method.version_in(_blank_calc()), str) + + version = method.version_in(calc=_completed_thf_calc()) + + assert version == "5.4.1" + + +@work_in_zipped_dir(qchem_data_zip_path) +def test_version_extract_broken_output_file(): + # Should not raise an exception + version = method.version_in(_broken_output_calc()) + assert isinstance(version, str) + + +@work_in_zipped_dir(qchem_data_zip_path) +def test_thf_calc_terminated_normally(): + assert _completed_thf_calc().terminated_normally + + +@work_in_zipped_dir(qchem_data_zip_path) +def test_terminated_abnormally(): + # Without any output the calculation cannot have terminated normally + calc = _blank_calc() + assert not method.terminated_normally_in(calc) + assert not calc.terminated_normally + + # A broken file containing one fewer H atom for an invalid 2S+1 + calc.output.filename = "smd_thf_broken.out" + assert calc.output.exists + assert not calc.terminated_normally + + # If the output is not a QChem output file.. + with open("tmp.out", "w") as tmp_out_file: + print("not", "a", "out", "file", sep="\n", file=tmp_out_file) + + calc = _blank_calc() + calc.output.filename = "tmp.out" + assert calc.output.exists + + assert not calc.terminated_normally + + os.remove("tmp.out") + + +def test_blank_input_generation(): + calc = _blank_calc() + calc.input.filename = None + + with pytest.raises(ValueError): + method.generate_input_for(calc=calc) + + +@work_in_tmp_dir(filenames_to_copy=[], kept_file_exts=[]) +def test_unsupported_keywords(): + calc = Calculation( + name="test", + molecule=Molecule(atoms=[Atom("H")], mult=2), + method=method, + keywords=SinglePointKeywords( + "$rem\n" "method b3lyp\n" "basis 6-31G*\n" "$end" + ), + ) + + # Having $blocks in the keywords is not supported + with pytest.raises(Exception): + calc.generate_input() + + +def test_simple_input_generation(): + expected_inp = ( + "$molecule\n" + "0 2\n" + "H 0.00000000 0.00000000 0.00000000 \n" + "$end\n" + "$rem\n" + "method pbe0\n" + "basis def2-SVP\n" + "mem_total 4000\n" + "$end\n" + ) + + # Simple PBE0/def2-SVP calculation of a hydrogen atom + h_atom = Molecule(atoms=[Atom("H")], mult=2) + calc = Calculation( + name="H_atom", + molecule=h_atom, + method=method, + keywords=SinglePointKeywords([pbe0, def2svp]), + ) + + # Generate the required input + calc.input.filename = "test.in" + method.generate_input_for(calc) + + # Remove any blank lines from the input file for comparison, as they are + # ignored + inp_lines = [ + line + for line in open(calc.input.filename, "r").readlines() + if line != "\n" + ] + assert "".join(inp_lines) == expected_inp + + os.remove("test.in") + + +@work_in_zipped_dir(qchem_data_zip_path) +def test_energy_extraction(): + calc = _completed_thf_calc() + + assert np.isclose(calc.molecule.energy.to("Ha"), -232.45463628, atol=1e-8) + + for calc in (_blank_calc(), _broken_output_calc(), _broken_output_calc2()): + with pytest.raises(CalculationException): + _ = method.energy_from(calc) + + +def _file_contains_one(filename, string): + """A file contains one line that is an exact match to a string""" + _list = [line.strip() for line in open(filename, "r")] + print(_list) + return sum(item.lower() == string for item in _list) == 1 + + +def _tmp_input_contains(string): + flag = _file_contains_one(filename="tmp.in", string=string) + os.remove("tmp.in") + return flag + + +def test_jobtype_inference(): + """Check that the jobtype can be infered from the keyword type""" + + def kwd_type_has_job_type(kwd_type, job_type, remove_explicit=True): + calc = _blank_calc() + calc.molecule = Molecule(atoms=[Atom("H"), Atom("H", x=0.77)]) + + keywords = getattr(method.keywords, kwd_type) + + if remove_explicit: + # Remove any explicit declaration of the job type in the keywords + keywords = keywords.__class__( + [w for w in keywords if "jobtype" not in w.lower()] + ) + + calc.input.keywords = keywords + + with QChem._InputFileWriter("tmp.in") as inp_file: + inp_file.add_rem_block(calc) + + return _tmp_input_contains(f"jobtype {job_type}") + + assert kwd_type_has_job_type("opt", "opt") + assert kwd_type_has_job_type("low_opt", "opt") + assert kwd_type_has_job_type("opt_ts", "ts", remove_explicit=False) + + assert kwd_type_has_job_type("grad", "force") + assert kwd_type_has_job_type("hess", "freq") + + +def test_ecp_writing(): + calc = _blank_calc() + calc.input.keywords = method.keywords.sp + + def write_tmp_input(): + with QChem._InputFileWriter("tmp.in") as inp_file: + inp_file.add_rem_block(calc) + + # No ECP for a H atom + assert calc.molecule.n_atoms == 1 and calc.molecule.atoms[0].label == "H" + write_tmp_input() + assert not _tmp_input_contains("ecp def2-ecp") + + # Should add an ECP for lead + calc.molecule = Molecule(atoms=[Atom("Pb")]) + write_tmp_input() + assert _tmp_input_contains("ecp def2-ecp") + + +@work_in_zipped_dir(qchem_data_zip_path) +def test_h2o_opt(): + """Check that the energy and geometry is extracted correctly""" + + h2o = Molecule(smiles="O") + h2o.optimise(method=method) + + assert h2o.energy is not None + assert np.isclose(h2o.energy.to("Ha"), -76.2766126261376, atol=1e-8) + + assert np.isclose(h2o.distance(0, 1).to("Å"), 0.962586, atol=1e-5) + + assert np.isclose(h2o.angle(1, 0, 2).to("deg"), 103.154810, atol=1e-3) + + +@work_in_zipped_dir(qchem_data_zip_path) +def test_gradient_extraction_h2o(): + h2o = Molecule(smiles="O") + calc = Calculation( + name="test", + molecule=h2o, + method=method, + keywords=OptKeywords(), + ) + + calc.set_output_filename("H2O_opt_qchem.out") + + assert calc.output.exists + + assert h2o.gradient.shape == (3, 3) + + # The minimum should have a gradient close to zero + assert np.allclose(h2o.gradient, np.zeros(shape=(3, 3)), atol=1e-4) + + # also for this calculation the optimisation has converged + assert calc.optimiser.converged + + +@work_in_zipped_dir(qchem_data_zip_path) +def test_gradient_extraction_h2(): + calc = _blank_calc() + calc.molecule = Molecule(atoms=[Atom("H"), Atom("H", x=0.77)]) + calc.set_output_filename("H2_qchem.out") + + assert calc.molecule.gradient.shape == (2, 3) + + +@work_in_zipped_dir(qchem_data_zip_path) +def test_butane_gradient_extraction(): + calc = _blank_calc() + calc.output.filename = "partial_C4H10_opt_qchem.out" + calc.molecule = Molecule(smiles="CCCC") + + assert calc.molecule.n_atoms == 14 + + grad = method.gradient_from(calc) + assert grad.shape == (14, 3) + + +@work_in_zipped_dir(qchem_data_zip_path) +def test_h2o_hessian_extraction(): + h2o = Molecule(smiles="O") + calc = _blank_calc() + calc.input.keywords = method.keywords.hess + calc.molecule = h2o + calc.set_output_filename("H2O_hess_qchem.out") + + hess = method.hessian_from(calc) + assert hess.shape == (9, 9) + + # Check the first element is close to that of an ORCA-derived equiv. + # in Ha / Å-2 + assert np.isclose(hess[0, 0], 2.31423829e00, atol=0.1) + + # Final atoms are available, as the same ones input + assert np.allclose( + calc.molecule.coordinates, + h2o.coordinates, + atol=1e-8, + ) + + +def test_broken_hessian_extraction(): + calc = _broken_output_calc() + + with pytest.raises(CalculationException): + _ = method.hessian_from(calc) + + calc = _custom_output_calc( + "some", "output", "then", "Mass-Weighted Hessian Matrix", "X" + ) + + with pytest.raises(CalculationException): + _ = method.hessian_from(calc) + + if os.path.exists("tmp.out"): + os.remove("tmp.out") + + +def test_broken_gradient_extraction(): + calc = _broken_output_calc() + + with pytest.raises(CalculationException): + _ = method.gradient_from(calc) + + calc = _custom_output_calc( + "some", "output", "then", "Mass-Weighted Hessian Matrix", "X" + ) + + with pytest.raises(CalculationException): + _ = method.gradient_from(calc) + + if os.path.exists("tmp.out"): + os.remove("tmp.out") + + +@work_in_zipped_dir(qchem_data_zip_path) +def test_calc_terminated_normally_max_opt_cycles(): + # h2o.optimise(method=ade.methods.QChem(), + # keywords=['method pbe', 'basis def2-SVP', 'geom_opt_max_cycle 2']) + + calc = _blank_calc() + calc.output.filename = "H2O_opt_max_2_cycles.out" + assert calc.output.exists + + # Even with a 'fatal error' in the output the calculation was ok + assert calc.terminated_normally + + +@work_in_zipped_dir(qchem_data_zip_path) +def test_ts_opt(): + Config.freq_scale_factor = 1.0 + + ts_mol = Molecule( + name="ts", + charge=-1, + mult=1, + solvent_name="water", + atoms=[ + Atom("F", -4.17085, 3.55524, 1.59944), + Atom("Cl", -0.75962, 3.53830, -0.72354), + Atom("C", -2.51988, 3.54681, 0.47836), + Atom("H", -3.15836, 3.99230, -0.27495), + Atom("H", -2.54985, 2.47411, 0.62732), + Atom("H", -2.10961, 4.17548, 1.25945), + ], + ) + + for pair in [(0, 2), (1, 2)]: + ts_mol.graph.add_active_edge(*pair) + + calc = Calculation( + name="sn2_ts", + molecule=ts_mol, + method=method, + keywords=method.keywords.opt_ts, + n_cores=4, + ) + + # Should skip calculation for already completed and saved calculation + calc.run() + + assert np.isclose(ts_mol.energy.to("Ha"), -599.4788133790, atol=1e-8) + assert ts_mol.hessian is not None + + assert sum(freq.is_imaginary for freq in ts_mol.vib_frequencies) == 1 + + # Should have a single imaginary frequency, ~511 cm-1 + assert np.isclose(ts_mol.vib_frequencies[0].to("cm-1"), -511, atol=2) + + +@work_in_zipped_dir(qchem_data_zip_path) +def test_constrained_distance_opt(): + mol = Molecule( + name="water_const_opt", + atoms=[ + Atom("O", -0.0011, 0.3631, -0.0000), + Atom("H", -0.8250, -0.1819, -0.0000), + Atom("H", 0.8261, -0.1812, 0.0000), + ], + ) + + # Constrain the O-H distance to 0.9 Å + mol.constraints.distance = {(0, 1): 0.9} + mol.optimise(method=method) + + assert np.isclose(mol.distance(0, 1).to("Å"), 0.9, atol=1e-2) + + +@work_in_zipped_dir(qchem_data_zip_path) +def test_constrained_cartesian_opt(): + mol = Molecule( + name="water_const_opt2", + atoms=[ + Atom("O", -0.0011, 0.3631, -0.0000), + Atom("H", -0.8250, -0.1819, -0.0000), + Atom("H", 0.8261, -0.1812, 0.0000), + ], + ) + + init_dist0, init_dist1 = mol.distance(0, 1), mol.distance(0, 2) + + mol.constraints.cartesian = [0, 1] + mol.optimise(method=method) + + # First O-H distance should be unchanged + assert np.isclose(mol.distance(0, 1), init_dist0, atol=1e-3) + + # while the other O-H distance will relax if atom 2 can move + assert not np.isclose(mol.distance(0, 2), init_dist1, atol=1e-3) + + +@work_in_tmp_dir(filenames_to_copy=[], kept_file_exts=[]) +def test_opt_single_atom(): + calc = _blank_calc() + calc.molecule = Molecule(name="H", mult=2, atoms=[Atom("H")]) + + calc.input.keywords = method.keywords.opt + + calc.name = "tmp" + calc.input.filename = "tmp.in" + + method.generate_input_for(calc=calc) + assert os.path.exists("tmp.in") + + # A single atom cannot be optimised so there should be no opt in the input + assert not any("opt" in line.lower() for line in open("tmp.in")) + + +@work_in_tmp_dir(filenames_to_copy=[], kept_file_exts=[]) +def test_unsupported_solvent_type(): + calc = _blank_calc() + + # Cannot generate a calculation with an unsupported solvent type + calc.input.keywords.append(cpcm) + + with pytest.raises(CalculationException): + calc.generate_input() + + +@work_in_zipped_dir(qchem_data_zip_path) +def test_butane_grad_extract(): + calc = _blank_calc() + calc.molecule = Molecule(smiles="CCCC") + calc.set_output_filename("C4H10_sp_qchem.out") + + assert calc.molecule.energy is not None + + grad = calc.molecule.gradient + assert grad is not None + flat_grad = grad.to("Ha a0^-1").flatten() + + # Check the final element of the gradient is as expected + assert np.isclose(flat_grad[-1], 0.0055454, atol=1e-5) + + assert np.isclose(flat_grad[5], 0.0263383, atol=1e-5) + + +@work_in_zipped_dir(qchem_data_zip_path) +def test_h2_coordinate_extraction_qchem_v6(): + calc = _blank_calc() + calc.input.keywords = method.keywords.opt + calc.molecule = Molecule(smiles="[H][H]") + calc.set_output_filename("H2_opt_qchem6.out") + + assert np.allclose( + calc.molecule.coordinates.to("Å"), + np.array([[+0.3803762086, 0.0, 0.0], [-0.3803762086, 0.0, 0.0]]), + ) + + +def test_coordinate_extract_from_single_point_calculation(): + calc = _blank_calc() + calc.input.keywords = method.keywords.sp + calc.molecule = Molecule(smiles="O") + init_coords = calc.molecule.coordinates.copy() + + assert np.allclose(init_coords, QChem().coordinates_from(calc)) + + +def test_coordinate_extract_from_single_atom_calculation(): + calc = _blank_calc() + calc.input.keywords = method.keywords.opt + calc.molecule = Molecule(atoms=[Atom("H", x=0, y=0, z=0)]) + + assert np.allclose(QChem().coordinates_from(calc), np.zeros(3)) + + +@work_in_tmp_dir() +def test_total_memory_is_printed_in_input_file(): + with temporary_config(): + Config.n_cores = 3 + Config.max_core = Allocation(900, "MB") + calc = _blank_calc() + calc.input.keywords = method.keywords.sp + calc.generate_input() + + input_lines = open(calc.input.filename, "r").readlines() + + assert sum("mem_total 2700" in line for line in input_lines) == 1 diff --git a/autodE/source/tests/test_wrappers/test_wrappers.py b/autodE/source/tests/test_wrappers/test_wrappers.py new file mode 100644 index 0000000000000000000000000000000000000000..be98bb772be52f08ff1af58f10555bcb1996788d --- /dev/null +++ b/autodE/source/tests/test_wrappers/test_wrappers.py @@ -0,0 +1,10 @@ +from autode.methods import ORCA, NWChem, G09, G16, MOPAC, XTB + + +def test_reprs(): + assert "orca" in repr(ORCA()).lower() + assert "nwchem" in repr(NWChem()).lower() + assert "gaussian" in repr(G09()).lower() + assert "gaussian" in repr(G16()).lower() + assert "mopac" in repr(MOPAC()).lower() + assert "xtb" in repr(XTB()).lower() diff --git a/autodE/source/tests/test_wrappers/test_xtb.py b/autodE/source/tests/test_wrappers/test_xtb.py new file mode 100644 index 0000000000000000000000000000000000000000..49161b44e09277a4d7da2ee35805cada7c3b954f --- /dev/null +++ b/autodE/source/tests/test_wrappers/test_xtb.py @@ -0,0 +1,404 @@ +from typing import List + +import numpy as np +import os +import pytest + +from autode.utils import work_in_tmp_dir, temporary_config +from autode.atoms import Atom +from autode.wrappers.XTB import XTB +from autode.calculations import Calculation +from autode.species.molecule import Molecule +from autode.point_charges import PointCharge +from autode.exceptions import CalculationException +from autode.wrappers.methods import ExternalMethodEGH +from autode.wrappers.keywords import OptKeywords, SinglePointKeywords +from autode.config import Config +from autode.hessians import Hessian +from autode.values import Coordinates, Gradient, PotentialEnergy +from .. import testutils + +here = os.path.dirname(os.path.abspath(__file__)) + +method = XTB() + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "xtb.zip")) +def test_xtb_calculation(): + test_mol = Molecule( + name="test_mol", smiles="O=C(C=C1)[C@@](C2NC3C=C2)([H])[C@@]3([H])C1=O" + ) + calc = Calculation( + name="opt", + molecule=test_mol, + method=method, + keywords=Config.XTB.keywords.opt, + ) + calc.run() + + assert os.path.exists("opt_xtb.xyz") is True + assert os.path.exists("opt_xtb.out") is True + assert test_mol.n_atoms == 22 + assert test_mol.energy == -36.990267613593 + assert calc.output.exists + assert calc.output.file_lines is not None + assert calc.input.filename == "opt_xtb.xyz" + assert calc.output.filename == "opt_xtb.out" + assert calc.optimiser.converged + + with pytest.raises(NotImplementedError): + _ = calc.optimiser.last_energy_change + + charges = test_mol.partial_charges + assert len(charges) == 22 + assert all(-1.0 < c < 1.0 for c in charges) + + test_mol.constraints.update(distance={(0, 1): 1.2539792}, cartesian=[0]) + + const_opt = Calculation( + name="const_opt", + molecule=test_mol, + method=method, + keywords=Config.XTB.keywords.opt, + ) + + const_opt.generate_input() + assert os.path.exists("const_opt_xtb.xyz") + assert os.path.exists("xcontrol_const_opt_xtb") + + const_opt.clean_up(force=True) + assert not os.path.exists("xcontrol_const_opt_xtb") + + # Write an empty output file + open("tmp.out", "w").close() + const_opt.output.filename = "tmp.out" + + # cannot get atoms from an empty file + with pytest.raises(CalculationException): + const_opt._executor.set_properties() + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "xtb.zip")) +def test_energy_extract_no_energy(): + calc = Calculation( + name="opt", + molecule=Molecule(smiles="[H][H]"), + method=method, + keywords=Config.XTB.keywords.sp, + ) + + # Output where the energy is not present + calc.output.filename = "h2_sp_xtb_no_energy.out" + + with pytest.raises(CalculationException): + calc._executor.set_properties() + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "xtb.zip")) +def test_point_charge(): + test_mol = Molecule(name="test_mol", smiles="C") + + # Methane with a point charge fairly far away + calc = Calculation( + name="opt_point_charge", + molecule=test_mol, + method=method, + keywords=Config.XTB.keywords.opt, + point_charges=[PointCharge(charge=1.0, x=10, y=1, z=1)], + ) + calc.run() + + assert -4.178 < test_mol.energy < -4.175 + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "xtb.zip")) +def test_gradients(): + h2 = Molecule(name="h2", atoms=[Atom("H"), Atom("H", x=1.0)]) + h2.single_point(method) + + delta_r = 1e-5 + h2_disp = Molecule( + name="h2_disp", atoms=[Atom("H"), Atom("H", x=1.0 + delta_r)] + ) + h2_disp.single_point(method) + + delta_energy = h2_disp.energy - h2.energy # Ha + grad = delta_energy / delta_r # Ha A^-1 + + calc = Calculation( + name="h2_grad", + molecule=h2, + method=method, + keywords=method.keywords.grad, + ) + + calc.run() + + diff = h2.gradient[1, 0] - grad # Ha A^-1 + + # Difference between the absolute and finite difference approximation + assert np.abs(diff) < 1e-5 + + # Older xtb version + with open(f"methane_OLD.grad", "w") as gradient_file: + print( + "$gradient\n" + "cycle = 1 SCF energy = -4.17404780397 |dE/dxyz| = 0.027866\n" + "3.63797523123375 -1.13138130908142 -0.00032759661848 C \n" + "5.72449332438353 -1.13197561185651 0.00028950521969 H \n" + " 2.94133258016711 0.22776472016180 -1.42078243039077 H \n" + " 2.94175598539510 -0.58111835182372 1.88747566982948 H \n" + "2.94180792167968 -3.04156357656436 -0.46665514803992 H \n" + "-1.7221823521705E-05 7.9930724499610E-05 -1.1737079840097E-04\n" + " 1.4116296505865E-02 -4.0359524399270E-05 3.9719638516747E-05\n" + "-4.7199424681741E-03 9.0086220034949E-03 -9.4114548523723E-03\n" + "-4.6956970257351E-03 3.6356853660431E-03 1.2558467871909E-02\n" + " -4.6834351884340E-03 -1.2683878569638E-02 -3.0693618596526E-03\n" + "$end", + file=gradient_file, + ) + + calc = Calculation( + name="methane", + molecule=Molecule(name="methane", smiles="C"), + method=method, + keywords=method.keywords.grad, + ) + gradients = method.gradient_from(calc) + + assert gradients.shape == (5, 3) + assert np.abs(gradients[0, 0]) < 1e-3 + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "xtb.zip")) +def test_xtb_6_3_2(): + mol = Molecule(name="CH3Cl", smiles="ClC") + calc = Calculation( + name="test", molecule=mol, method=method, keywords=method.keywords.opt + ) + + calc.set_output_filename("xtb_6_3_2_opt.out") + assert mol.n_atoms == 5 + assert np.isclose(mol.atoms[-2].coord[1], -0.47139030225766) + + +@testutils.work_in_zipped_dir(os.path.join(here, "data", "xtb.zip")) +def test_xtb_6_1_old(): + mol = Molecule(name="methane", smiles="C") + calc = Calculation( + name="test", molecule=mol, method=method, keywords=method.keywords.opt + ) + + # TODO: check this extracts the right numbers + for filename in ("xtb_6_1_opt.out", "xtb_no_version_opt.out"): + calc.set_output_filename(filename) + + assert set([atom.label for atom in mol.atoms]) == {"C", "H"} + assert 0.9 < mol.distance(0, 1) < 1.2 + + +class XTBautodEOpt(ExternalMethodEGH, XTB): + __test__ = False + + def __init__(self): + ExternalMethodEGH.__init__( + self, + executable_name="xtb", + doi_list=[], + implicit_solvation_type=None, + keywords_set=XTB().keywords, + ) + self.electronic_temp = XTB().electronic_temp + self.gfn_version = XTB().gfn_version + + def _energy_from(self, calc: "CalculationExecutor") -> PotentialEnergy: + return XTB._energy_from(self, calc) + + def gradient_from(self, calc: "CalculationExecutor") -> Gradient: + return XTB.gradient_from(self, calc) + + def hessian_from( + self, calc: "autode.calculations.executors.CalculationExecutor" + ) -> Hessian: + pass + + def coordinates_from(self, calc: "CalculationExecutor") -> Coordinates: + pass + + def partial_charges_from(self, calc: "CalculationExecutor") -> List[float]: + pass + + def terminated_normally_in(self, calc: "CalculationExecutor") -> bool: + return True + + def version_in(self, calc: "CalculationExecutor") -> str: + pass + + @staticmethod + def input_filename_for(calc: "CalculationExecutor") -> str: + return XTB.input_filename_for(calc) + + @staticmethod + def output_filename_for(calc: "CalculationExecutor") -> str: + return XTB.output_filename_for(calc) + + def generate_input_for(self, calc: "CalculationExecutor") -> None: + calc.molecule.print_xyz_file(filename=calc.input.filename) + return None + + def __repr__(self): + return XTB.__repr__(self) + + +@testutils.requires_working_xtb_install +@work_in_tmp_dir() +def test_xtb_with_autode_opt_method(): + mol = Molecule(smiles="C") + calc = Calculation( + name="methane", + molecule=mol, + method=XTBautodEOpt(), + keywords=OptKeywords(), + ) + calc.run() + + assert calc.optimiser.converged + + +@testutils.requires_working_xtb_install +@work_in_tmp_dir() +def test_xtb_with_autode_opt_method_for_a_single_atom(): + mol = Molecule(atoms=[Atom("H")], mult=2) + calc = Calculation( + name="h_atom", + molecule=mol, + method=XTBautodEOpt(), + keywords=OptKeywords(), + ) + calc.run() + + assert calc.optimiser.converged + assert mol.energy is not None + + +@testutils.requires_working_xtb_install +@work_in_tmp_dir() +def test_xtb_opt_non_contiguous_range_cart_constraints(): + mol = Molecule(smiles="CC") + mol.constraints.cartesian = [0, 1, 2, 5] + + calc = Calculation( + name="ethane", molecule=mol, method=XTB(), keywords=OptKeywords() + ) + calc.run() + + assert len(calc.input.additional_filenames) > 0 + xcontrol_lines = open(calc.input.additional_filenames[-1], "r").readlines() + expected_range = "1-3,6" + assert sum(expected_range in line for line in xcontrol_lines) == 1 + + assert calc.optimiser.converged + assert mol.energy is not None + + +@testutils.requires_working_xtb_install +@work_in_tmp_dir() +def test_xtb_errors_with_infinite_nuclear_repulsion(): + # H2 with a zero H-H distance + mol = Molecule(atoms=[Atom("H"), Atom("H")]) + calc = Calculation( + name="h_atom", + molecule=mol, + method=XTB(), + keywords=SinglePointKeywords(), + ) + + with pytest.raises(CalculationException): + calc.run() + + +@work_in_tmp_dir() +def test_xtb_did_not_terminate_normally_with_blank_output(): + mol = Molecule(atoms=[Atom("H")], mult=2) + calc = Calculation( + name="h_atom", + molecule=mol, + method=XTB(), + keywords=SinglePointKeywords(), + ) + + with open("tmp.out", "w") as file: + print("\n", file=file) + + calc._executor.output.filename = "tmp.out" + assert not calc.method.terminated_normally_in(calc) + + +@testutils.requires_working_xtb_install +@work_in_tmp_dir() +def test_ade_opt_rerun_with_different_input_skip_saved_opt(): + def run_calc(_mol): + calc = Calculation( + name="water", + molecule=_mol, + method=XTBautodEOpt(), + keywords=OptKeywords(), + ) + calc.run() + + mol = Molecule(smiles="O") + run_calc(mol) + + unconstrained_energy = mol.energy.copy() + + mol.constraints.distance = {(0, 1): 0.9} + run_calc(mol) + + assert mol.energy != unconstrained_energy + + +@testutils.requires_working_xtb_install +@work_in_tmp_dir() +def test_xtb_cartesian_constrained_opt(): + init_r = 0.9 + h2 = Molecule(atoms=[Atom("H"), Atom("H", x=init_r)]) + + h2_unconstrained = h2.new_species(name="unconstrained_h2") + h2_unconstrained.optimise(method=XTB()) + # expected minimum for H2 is ~0.77 Å + assert abs(h2_unconstrained.distance(0, 1) - init_r) > 0.1 + + h2.constraints.cartesian = [0, 1] + h2.optimise(method=XTB()) + + # if the coordinates are constrained then the distance should be + # close to the initial + assert abs(h2.distance(0, 1) - init_r) < 0.1 + + +@pytest.mark.parametrize("gfn_ver,etemp", [(1, 300.0), (2, 1200.1)]) +@testutils.requires_working_xtb_install +@work_in_tmp_dir() +def test_xtb_etemp_and_gfn_var_params_recognised(gfn_ver, etemp): + # test that the electronic_temp and gfn_version Config params are recognised + + mol = Molecule(smiles="O") + with temporary_config(): + Config.XTB.gfn_version = gfn_ver + Config.XTB.electronic_temp = etemp + + calc = Calculation( + name="water", + molecule=mol, + method=XTB(), + keywords=XTB().keywords.sp, + ) + calc.run() + + assert calc.output.filename is not None + output = "".join(calc.output.file_lines) + + # xTB command line flags should be printed in the output + assert f"--gfn {gfn_ver}" in output + etemp_str = str(float(etemp)) # string repr may be different + assert f"--etemp {etemp_str}" in output diff --git a/autodE/source/tests/testutils.py b/autodE/source/tests/testutils.py new file mode 100644 index 0000000000000000000000000000000000000000..44e09356f621a46deeb6ebb851cd7ae5f5232af3 --- /dev/null +++ b/autodE/source/tests/testutils.py @@ -0,0 +1,64 @@ +import os +import shutil +from autode.utils import cleanup_after_timeout +from zipfile import ZipFile +from functools import wraps + + +def unzip_dir(zip_path): + return work_in_zipped_dir(zip_path, chdir=False) + + +def work_in_zipped_dir(zip_path, chdir=True): + """Extract some data from a compressed folder, change directories to it if + required, run the function then, if required change directories back out + and then delete the generated folder""" + assert zip_path.endswith(".zip") + + def func_decorator(func): + @wraps(func) + def wrapped_function(*args, **kwargs): + # Remove the .zip extension - rstrip doesn't seem to work + # consistently(?) + dir_path = zip_path[:-4] + + extract_path = os.path.split(dir_path)[0] + here = os.getcwd() + + with ZipFile(zip_path, "r") as zip_folder: + zip_folder.extractall(extract_path) + + if chdir: + os.chdir(dir_path) + + try: + result = func(*args, **kwargs) + + finally: + if chdir: + os.chdir(here) + + cleanup_after_timeout() + shutil.rmtree(dir_path) + + return result + + return wrapped_function + + return func_decorator + + +def requires_working_xtb_install(func): + """A function requiring an output file and output file lines""" + + @wraps(func) + def wrapped_function(*args, **kwargs): + if not shutil.which("xtb"): + return + + if not shutil.which("xtb").lower().endswith(("xtb", "xtb.exe")): + return + + return func(*args, **kwargs) + + return wrapped_function diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..44f0244cbc2cda9509615d6e676ba0867f24d110 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,13 @@ +fastmcp +fastapi +uvicorn[standard] +pydantic>=2.0.0 +rdkit +numpy +networkx +matplotlib +pillow>=9.5.0 +cython +scipy +loky +ase diff --git a/run_docker.ps1 b/run_docker.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..cf5af245f693ae086bda3c0a35ca2d9ef92a588b --- /dev/null +++ b/run_docker.ps1 @@ -0,0 +1,35 @@ +cd $PSScriptRoot + +$ErrorActionPreference = "Stop" + +$entryName = if ($env:MCP_ENTRY_NAME) { $env:MCP_ENTRY_NAME } else { "autodE" } +$entryUrl = if ($env:MCP_ENTRY_URL) { $env:MCP_ENTRY_URL } else { "http://localhost:7860/mcp" } +$imageName = if ($env:MCP_IMAGE_NAME) { $env:MCP_IMAGE_NAME } else { "autodE-mcp" } + +$mcpDir = Join-Path $env:USERPROFILE ".cursor" +$mcpPath = Join-Path $mcpDir "mcp.json" +if (!(Test-Path $mcpDir)) { New-Item -ItemType Directory -Path $mcpDir | Out-Null } + +$config = @{} +if (Test-Path $mcpPath) { + try { $config = Get-Content $mcpPath -Raw | ConvertFrom-Json } catch { $config = @{} } +} + +# Rebuild mcpServers as ordered and append the entry last +$serversOrdered = [ordered]@{} +if ($config -and ($config.PSObject.Properties.Name -contains "mcpServers") -and $config.mcpServers) { + $existing = $config.mcpServers + if ($existing -is [pscustomobject]) { + foreach ($p in $existing.PSObject.Properties) { if ($p.Name -ne $entryName) { $serversOrdered[$p.Name] = $p.Value } } + } elseif ($existing -is [System.Collections.IDictionary]) { + foreach ($k in $existing.Keys) { if ($k -ne $entryName) { $serversOrdered[$k] = $existing[$k] } } + } +} +$serversOrdered[$entryName] = @{ url = $entryUrl } +$config = @{ mcpServers = $serversOrdered } + +$config | ConvertTo-Json -Depth 10 | Set-Content -Path $mcpPath -Encoding UTF8 +Write-Host ("Updated $entryName in " + $mcpPath + " -> " + $entryUrl) + +docker build -t $imageName . +docker run --rm -p 7860:7860 $imageName diff --git a/run_docker.sh b/run_docker.sh new file mode 100644 index 0000000000000000000000000000000000000000..f5193635bcae420bbebc3ef272c9b2b9c883c925 --- /dev/null +++ b/run_docker.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Switch to the directory where this script is located +cd "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +mcp_entry_name="${MCP_ENTRY_NAME:-autodE}" +mcp_entry_url="${MCP_ENTRY_URL:-http://localhost:7860/mcp}" +mcp_dir="${HOME}/.cursor" +mcp_path="${mcp_dir}/mcp.json" +mkdir -p "${mcp_dir}" + +if command -v python3 >/dev/null 2>&1; then +python3 - "${mcp_path}" "${mcp_entry_name}" "${mcp_entry_url}" <<'PY' +import json, os, sys +path, name, url = sys.argv[1:4] +cfg = {"mcpServers": {}} +if os.path.exists(path): + try: + with open(path, "r", encoding="utf-8") as f: + cfg = json.load(f) + except Exception: + cfg = {"mcpServers": {}} +if not isinstance(cfg, dict): + cfg = {"mcpServers": {}} +servers = cfg.get("mcpServers") +if not isinstance(servers, dict): + servers = {} +ordered = {} +for k, v in servers.items(): + if k != name: + ordered[k] = v +ordered[name] = {"url": url} +cfg = {"mcpServers": ordered} +with open(path, "w", encoding="utf-8") as f: + json.dump(cfg, f, indent=2, ensure_ascii=False) +PY +elif command -v python >/dev/null 2>&1; then +python - "${mcp_path}" "${mcp_entry_name}" "${mcp_entry_url}" <<'PY' +import json, os, sys +path, name, url = sys.argv[1:4] +cfg = {"mcpServers": {}} +if os.path.exists(path): + try: + with open(path, "r", encoding="utf-8") as f: + cfg = json.load(f) + except Exception: + cfg = {"mcpServers": {}} +if not isinstance(cfg, dict): + cfg = {"mcpServers": {}} +servers = cfg.get("mcpServers") +if not isinstance(servers, dict): + servers = {} +ordered = {} +for k, v in servers.items(): + if k != name: + ordered[k] = v +ordered[name] = {"url": url} +cfg = {"mcpServers": ordered} +with open(path, "w", encoding="utf-8") as f: + json.dump(cfg, f, indent=2, ensure_ascii=False) +PY +elif command -v jq >/dev/null 2>&1; then + name="${mcp_entry_name}"; url="${mcp_entry_url}" + if [ -f "${mcp_path}" ]; then + tmp="$(mktemp)" + jq --arg name "$name" --arg url "$url" ' + .mcpServers = (.mcpServers // {}) + | .mcpServers as $s + | ($s | with_entries(select(.key != $name))) as $base + | .mcpServers = ($base + {($name): {"url": $url}}) + ' "${mcp_path}" > "${tmp}" && mv "${tmp}" "${mcp_path}" + else + printf '{ "mcpServers": { "%s": { "url": "%s" } } } +' "$name" "$url" > "${mcp_path}" + fi +else + echo "Warning: neither python nor jq found; skipped updating ~/.cursor/mcp.json" >&2 +fi + +docker build -t autodE-mcp . +docker run --rm -p 7860:7860 autodE-mcp