diff --git a/.github/workflows/devel.yaml b/.github/workflows/devel.yaml index 3ab8dab..1dded44 100644 --- a/.github/workflows/devel.yaml +++ b/.github/workflows/devel.yaml @@ -3,7 +3,7 @@ name: Release Devel on: workflow_dispatch: push: - branches: [ devel ] + branches: [devel] jobs: build: @@ -18,11 +18,9 @@ jobs: - { name: "linux", os: "ubuntu-latest", shell: "bash -l {0}" } - { name: "macos", os: "macos-latest", shell: "bash -l {0}" } exclude: - # Exclude all but the latest Python from all - # but Linux - platform: { name: "macos", os: "macos-latest", shell: "bash -l {0}" } - python-version: "3.12" # MacOS can't run 3.12 yet... We want 3.10 and 3.11 + python-version: "3.12" # MacOS can't run 3.12 yet... We want 3.10 and 3.11 environment: name: ghostly-build defaults: @@ -32,30 +30,38 @@ jobs: SIRE_DONT_PHONEHOME: 1 SIRE_SILENT_PHONEHOME: 1 steps: - - uses: conda-incubator/setup-miniconda@v3 + # + - uses: actions/checkout@v4 with: - auto-update-conda: true - python-version: ${{ matrix.python-version }} - activate-environment: ghostly_build - miniforge-version: latest -# - - name: Clone the devel branch - run: git clone -b devel https://github.com/openbiosim/ghostly -# - - name: Setup Conda - run: conda install -y -c conda-forge boa anaconda-client packaging -# - - name: Update Conda recipe - run: python ${{ github.workspace }}/ghostly/actions/update_recipe.py -# - - name: Prepare build location - run: mkdir ${{ github.workspace }}/build -# - - name: Build Conda package using conda build - run: conda build -c conda-forge -c openbiosim/label/dev ${{ github.workspace }}/ghostly/recipes/ghostly -# - - name: Upload Conda package - run: python ${{ github.workspace }}/ghostly/actions/upload_package.py + fetch-depth: 0 + # + - name: Compute version info + run: python ${{ github.workspace }}/actions/update_recipe.py + # + - name: Create sdist + run: pip install build && python -m build --sdist && mv dist/*.tar.gz ghostly-source.tar.gz + working-directory: ${{ github.workspace }} + # + - name: Install rattler-build + uses: prefix-dev/rattler-build-action@v0.2.34 + with: + tool-version: latest + build-args: --help + # + - name: Write Python variant config + shell: bash + run: printf 'python:\n - "${{ matrix.python-version }}"\n' > "${{ github.workspace }}/python_variant.yaml" + # + - name: Build package using rattler-build + shell: bash + run: rattler-build build --recipe "${{ github.workspace }}/recipes/ghostly" -c conda-forge -c openbiosim/label/dev --variant-config "${{ github.workspace }}/python_variant.yaml" + # + - name: Install anaconda-client + run: python -m pip install anaconda-client + if: ${{ matrix.platform.name == 'linux' && matrix.python-version == '3.11' }} + # + - name: Upload package + run: python ${{ github.workspace }}/actions/upload_package.py env: ANACONDA_TOKEN: ${{ secrets.ANACONDA_TOKEN }} ANACONDA_LABEL: dev diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 7ef9819..c4916db 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -23,7 +23,7 @@ jobs: exclude: - platform: { name: "macos", os: "macos-latest", shell: "bash -l {0}" } - python-version: "3.12" # MacOS can't run 3.12 yet... + python-version: "3.12" # MacOS can't run 3.12 yet... environment: name: ghostly-build defaults: @@ -33,30 +33,39 @@ jobs: SIRE_DONT_PHONEHOME: 1 SIRE_SILENT_PHONEHOME: 1 steps: - - uses: conda-incubator/setup-miniconda@v3 + # + - uses: actions/checkout@v4 with: - auto-update-conda: true - python-version: ${{ matrix.python-version }} - activate-environment: ghostly_build - miniforge-version: latest -# - - name: Clone the main branch - run: git clone -b main https://github.com/openbiosim/ghostly -# - - name: Setup Conda - run: conda install -y -c conda-forge boa anaconda-client packaging -# - - name: Update Conda recipe - run: python ${{ github.workspace }}/ghostly/actions/update_recipe.py -# - - name: Prepare build location - run: mkdir ${{ github.workspace }}/build -# - - name: Build Conda package using conda build - run: conda build -c conda-forge -c openbiosim/label/main ${{ github.workspace }}/ghostly/recipes/ghostly -# - - name: Upload Conda package - run: python ${{ github.workspace }}/ghostly/actions/upload_package.py + ref: main + fetch-depth: 0 + # + - name: Compute version info + run: python ${{ github.workspace }}/actions/update_recipe.py + # + - name: Create sdist + run: pip install build && python -m build --sdist && mv dist/*.tar.gz ghostly-source.tar.gz + working-directory: ${{ github.workspace }} + # + - name: Install rattler-build + uses: prefix-dev/rattler-build-action@v0.2.34 + with: + tool-version: latest + build-args: --help + # + - name: Write Python variant config + shell: bash + run: printf 'python:\n - "${{ matrix.python-version }}"\n' > "${{ github.workspace }}/python_variant.yaml" + # + - name: Build package using rattler-build + shell: bash + run: rattler-build build --recipe "${{ github.workspace }}/recipes/ghostly" -c conda-forge -c openbiosim/label/main --variant-config "${{ github.workspace }}/python_variant.yaml" + # + - name: Install anaconda-client + run: python -m pip install anaconda-client + if: github.event.inputs.upload_packages == 'true' && matrix.platform.name == 'linux' && matrix.python-version == '3.11' + # + - name: Upload package + run: python ${{ github.workspace }}/actions/upload_package.py env: ANACONDA_TOKEN: ${{ secrets.ANACONDA_TOKEN }} ANACONDA_LABEL: main diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 6b56abc..8952da4 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -17,14 +17,12 @@ jobs: - { name: "linux", os: "ubuntu-latest", shell: "bash -l {0}" } - { name: "macos", os: "macos-latest", shell: "bash -l {0}" } exclude: - # Exclude all but the latest Python from all - # but Linux - platform: { name: "macos", os: "macos-latest", shell: "bash -l {0}" } python-version: "3.10" - platform: { name: "macos", os: "macos-latest", shell: "bash -l {0}" } - python-version: "3.12" # MacOS can't run 3.12 yet... + python-version: "3.12" # MacOS can't run 3.12 yet... environment: name: ghostly-build defaults: @@ -35,29 +33,34 @@ jobs: SIRE_SILENT_PHONEHOME: 1 REPO: "${{ github.event.pull_request.head.repo.full_name || github.repository }}" steps: - - uses: conda-incubator/setup-miniconda@v3 + # + - uses: actions/checkout@v4 with: - auto-update-conda: true - python-version: ${{ matrix.python-version }} - activate-environment: ghostly_build - miniforge-version: latest -# - - name: Clone the feature branch - run: git clone -b ${{ github.head_ref }} --single-branch https://github.com/${{ env.REPO }} ghostly -# - - name: Setup Conda - run: conda install -y -c conda-forge boa anaconda-client packaging -# - - name: Update Conda recipe - run: python ${{ github.workspace }}/ghostly/actions/update_recipe.py -# - - name: Prepare build location - run: mkdir ${{ github.workspace }}/build -# - - name: Build Conda package using conda build using main channel + fetch-depth: 0 + # + - name: Compute version info + run: python ${{ github.workspace }}/actions/update_recipe.py + # + - name: Create sdist + run: pip install build && python -m build --sdist && mv dist/*.tar.gz ghostly-source.tar.gz + working-directory: ${{ github.workspace }} + # + - name: Install rattler-build + uses: prefix-dev/rattler-build-action@v0.2.34 + with: + tool-version: latest + build-args: --help + # + - name: Write Python variant config + shell: bash + run: printf 'python:\n - "${{ matrix.python-version }}"\n' > "${{ github.workspace }}/python_variant.yaml" + # + - name: Build package using rattler-build (main channel) if: ${{ github.base_ref == 'main' }} - run: conda build -c conda-forge -c openbiosim/label/main ${{ github.workspace }}/ghostly/recipes/ghostly -# - - name: Build Conda package using conda build using dev channel + shell: bash + run: rattler-build build --recipe "${{ github.workspace }}/recipes/ghostly" -c conda-forge -c openbiosim/label/main --variant-config "${{ github.workspace }}/python_variant.yaml" + # + - name: Build package using rattler-build (dev channel) if: ${{ github.base_ref != 'main' }} - run: conda build -c conda-forge -c openbiosim/label/dev ${{ github.workspace }}/ghostly/recipes/ghostly + shell: bash + run: rattler-build build --recipe "${{ github.workspace }}/recipes/ghostly" -c conda-forge -c openbiosim/label/dev --variant-config "${{ github.workspace }}/python_variant.yaml" diff --git a/.gitignore b/.gitignore index e232e0e..750d974 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ recipes/ghostly/meta.yaml # Sire cache files. cache/ + +# Auto-generated version file. +src/ghostly/_version.py diff --git a/README.md b/README.md index 3598949..c9c4d1d 100644 --- a/README.md +++ b/README.md @@ -30,41 +30,60 @@ See the [examples](examples) directory for more details. ## Installation -First create a conda environment using the provided environment file: +### Conda package + +Install `ghostly` directly from the `openbiosim` channel: ``` -conda env create -f environment.yaml +conda install -c conda-forge -c openbiosim ghostly ``` -(We recommend using [Miniforge](https://github.com/conda-forge/miniforge).) - -Now install `ghostly` into the environment: +Or, for the development version: ``` -conda activate ghostly -pip install . +conda install -c conda-forge -c openbiosim/label/dev ghostly ``` -Or, for an editable install (useful for development): +### Installing from source (standalone) + +To install from source using [pixi](https://pixi.sh), which will +automatically create an environment with all required dependencies +(including pre-built [Sire](https://github.com/OpenBioSim/sire) and +[BioSimSpace](https://github.com/OpenBioSim/biosimspace)): ``` -conda activate ghostly +git clone https://github.com/openbiosim/ghostly +cd ghostly +pixi install +pixi shell pip install -e . ``` -For an existing conda environment, you can also install `ghostly` directly from -the `openbiosim` channel: +### Installing from source (full OpenBioSim development) + +If you are developing across the full OpenBioSim stack, first install +[Sire](https://github.com/OpenBioSim/sire) from source by following the +instructions [here](https://github.com/OpenBioSim/sire#installation), then +activate its pixi environment: ``` -conda install -c conda-forge -c openbiosim ghostly +pixi shell --manifest-path /path/to/sire/pixi.toml -e dev ``` -Or, for the development version: +You may also need to install other packages from source, e.g. +[BioSimSpace](https://github.com/OpenBioSim/biosimspace): ``` -conda install -c conda-forge -c openbiosim/label/dev ghostly +pip install -e /path/to/biosimspace/python +``` + +Then install `ghostly` into the environment: + +``` +pip install -e . ``` +### Testing You should now have a `ghostly` executable in your path. To test, run: diff --git a/actions/update_recipe.py b/actions/update_recipe.py index 16276ed..cd9a0fc 100644 --- a/actions/update_recipe.py +++ b/actions/update_recipe.py @@ -1,50 +1,58 @@ -import sys +"""Compute git version info for rattler-build. + +This script computes GIT_DESCRIBE_TAG and GIT_DESCRIBE_NUMBER from the +git history and outputs them in GitHub Actions format for setting +environment variables. + +It also writes a _version.py file so that versioningit has a fallback +when .git is not available (e.g., when rattler-build excludes it). +""" + import os import subprocess +import sys -# Get the name of the script. script = os.path.abspath(sys.argv[0]) - -# we want to import the 'get_requirements' package from this directory -sys.path.insert(0, os.path.dirname(script)) - -# go up one directories to get the source directory -# (this script is in BioSimSpace/actions/) srcdir = os.path.dirname(os.path.dirname(script)) - -condadir = os.path.join(srcdir, "recipes", "ghostly") - -print(f"conda recipe in {condadir}") - -# Store the name of the recipe and template YAML files. -recipe = os.path.join(condadir, "meta.yaml") -template = os.path.join(condadir, "template.yaml") - gitdir = os.path.join(srcdir, ".git") def run_cmd(cmd): - p = subprocess.Popen(cmd.split(), stdout=subprocess.PIPE) - return str(p.stdout.read().decode("utf-8")).lstrip().rstrip() - - -# Get the remote. -remote = run_cmd( - f"git --git-dir={gitdir} --work-tree={srcdir} config --get remote.origin.url" -) -print(remote) - -# Get the branch. -branch = run_cmd( - f"git --git-dir={gitdir} --work-tree={srcdir} rev-parse --abbrev-ref HEAD" -) -print(branch) - -lines = open(template, "r").readlines() - -with open(recipe, "w") as FILE: - for line in lines: - line = line.replace("GHOSTLY_REMOTE", remote) - line = line.replace("GHOSTLY_BRANCH", branch) - - FILE.write(line) + p = subprocess.Popen(cmd.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE) + stdout, _ = p.communicate() + return stdout.decode("utf-8").strip() + + +# Get the full git describe output (e.g., "2024.1.0-5-gabcdef" or "2024.1.0") +describe = run_cmd(f"git --git-dir={gitdir} --work-tree={srcdir} describe --tags") + +if "-" in describe: + # Format: tag-number-hash (e.g., "2024.1.0-5-gabcdef") + parts = describe.rsplit("-", 2) + tag = parts[0] + number = parts[1] + rev = parts[2] # e.g., "gabcdef" + version = f"{tag}+{number}.{rev}" +else: + # Exactly on a tag + tag = describe + number = "0" + version = tag + +print(f"GIT_DESCRIBE_TAG={tag}") +print(f"GIT_DESCRIBE_NUMBER={number}") +print(f"Version={version}") + +# Write to GITHUB_ENV if running in GitHub Actions +github_env = os.environ.get("GITHUB_ENV") +if github_env: + with open(github_env, "a") as f: + f.write(f"GIT_DESCRIBE_TAG={tag}\n") + f.write(f"GIT_DESCRIBE_NUMBER={number}\n") + print("Exported to GITHUB_ENV") + +# Write _version.py for versioningit fallback +version_file = os.path.join(srcdir, "src", "ghostly", "_version.py") +with open(version_file, "w") as f: + f.write(f'__version__ = "{version}"\n') +print(f"Wrote {version_file}") diff --git a/actions/upload_package.py b/actions/upload_package.py index 504bea7..fc3435f 100644 --- a/actions/upload_package.py +++ b/actions/upload_package.py @@ -1,16 +1,18 @@ +"""Upload built packages to the openbiosim Anaconda Cloud channel.""" + import os import sys import glob +import subprocess script = os.path.abspath(sys.argv[0]) -# go up one directories to get the source directory -# (this script is in ghostly/actions/) +# Go up one directory to get the source directory. srcdir = os.path.dirname(os.path.dirname(script)) print(f"Ghostly source is in {srcdir}\n") -# Get the anaconda token to authorise uploads +# Get the anaconda token to authorise uploads. if "ANACONDA_TOKEN" in os.environ: conda_token = os.environ["ANACONDA_TOKEN"] else: @@ -22,42 +24,30 @@ else: conda_label = "dev" -# get the root conda directory -conda = os.environ["CONDA"] - -# Set the path to the conda-bld directory. -conda_bld = os.path.join(conda, "envs", "ghostly_build", "conda-bld") - -print(f"conda_bld = {conda_bld}") +# Search for rattler-build output first. +packages = glob.glob(os.path.join("output", "**", "*.conda"), recursive=True) -# Find the packages to upload -ghostly_pkg = glob.glob(os.path.join(conda_bld, "noarch", "ghostly-*.tar.bz2")) +# Fall back to conda-bld output. +if not packages: + if "CONDA" in os.environ: + conda = os.environ["CONDA"] + conda_bld = os.path.join(conda, "envs", "ghostly_build", "conda-bld") + packages = glob.glob( + os.path.join(conda_bld, "**", "ghostly-*.tar.bz2"), recursive=True + ) -if len(ghostly_pkg) == 0: +if not packages: print("No ghostly packages to upload?") sys.exit(-1) -packages = ghostly_pkg - -print(f"Uploading packages:") -print(" * ", "\n * ".join(packages)) - -packages = " ".join(packages) - - -def run_cmd(cmd): - import subprocess - - p = subprocess.Popen(cmd.split(), stdout=subprocess.PIPE) - return str(p.stdout.read().decode("utf-8")).lstrip().rstrip() +print("Uploading packages:") +for pkg in packages: + print(f" * {pkg}") - -gitdir = os.path.join(srcdir, ".git") - -tag = run_cmd(f"git --git-dir={gitdir} --work-tree={srcdir} tag --contains") +packages_str = " ".join(packages) # Upload the packages to the openbiosim channel on Anaconda Cloud. -cmd = f"anaconda --token {conda_token} upload --user openbiosim --label {conda_label} --force {packages}" +cmd = f"anaconda --token {conda_token} upload --user openbiosim --label {conda_label} --force {packages_str}" print(f"\nUpload command:\n\n{cmd}\n") @@ -65,8 +55,12 @@ def run_cmd(cmd): print("Not uploading as the ANACONDA_TOKEN is not set!") sys.exit(-1) -output = run_cmd(cmd) -print(output) +def run_cmd(cmd): + p = subprocess.Popen(cmd.split(), stdout=subprocess.PIPE) + return str(p.stdout.read().decode("utf-8")).lstrip().rstrip() + +output = run_cmd(cmd) +print(output) print("Package uploaded!") diff --git a/environment.yaml b/environment.yaml deleted file mode 100644 index 3fee8fc..0000000 --- a/environment.yaml +++ /dev/null @@ -1,9 +0,0 @@ -name: ghostly - -channels: - - conda-forge - - openbiosim/label/dev - -dependencies: - - biosimspace - - loguru diff --git a/pixi.toml b/pixi.toml new file mode 100644 index 0000000..360efa1 --- /dev/null +++ b/pixi.toml @@ -0,0 +1,18 @@ +[workspace] +name = "ghostly" +channels = ["conda-forge", "openbiosim/label/dev"] +platforms = ["linux-64", "linux-aarch64", "osx-arm64", "win-64"] + +[dependencies] +python = ">=3.10" +biosimspace = "*" +loguru = "*" + +[feature.test.dependencies] +pytest = "*" +black = "*" + +[environments] +default = [] +test = ["test"] +dev = ["test"] diff --git a/recipes/ghostly/conda_build_config.yaml b/recipes/ghostly/conda_build_config.yaml deleted file mode 100644 index 3e8e203..0000000 --- a/recipes/ghostly/conda_build_config.yaml +++ /dev/null @@ -1,3 +0,0 @@ -pin_run_as_build: - sire: - max_pin: x.x diff --git a/recipes/ghostly/recipe.yaml b/recipes/ghostly/recipe.yaml new file mode 100644 index 0000000..8279192 --- /dev/null +++ b/recipes/ghostly/recipe.yaml @@ -0,0 +1,52 @@ +context: + name: ghostly + +package: + name: ${{ name }} + version: ${{ env.get('GIT_DESCRIBE_TAG', default='PR') }} + +source: + path: ../../ghostly-source.tar.gz + +build: + number: ${{ env.get('GIT_DESCRIBE_NUMBER', default='0') }} + noarch: python + script: python -m pip install . --no-deps --ignore-installed -vv + +requirements: + host: + - pip + - python + - setuptools + - versioningit + run: + - biosimspace + - loguru + - numpy <2.3 # Remove when nglview >=4.1 is released + - python + +tests: + - python: + imports: + - ghostly + - script: + - pytest -vvv --color=yes --import-mode=importlib ./tests + files: + source: + - src/ghostly/ + - tests/ + requirements: + run: + - pytest + +about: + homepage: https://github.com/openbiosim/ghostly + license: GPL-3.0-or-later + license_file: LICENSE + summary: "Ghost atom bonded term modifications for alchemical free-energy simulations." + repository: https://github.com/openbiosim/ghostly + documentation: https://github.com/openbiosim/ghostly + +extra: + recipe-maintainers: + - lohedges diff --git a/recipes/ghostly/template.yaml b/recipes/ghostly/template.yaml deleted file mode 100644 index 475b629..0000000 --- a/recipes/ghostly/template.yaml +++ /dev/null @@ -1,83 +0,0 @@ -{% set name = "ghostly" %} - -package: - name: {{ name }} - version: {{ environ.get('GIT_DESCRIBE_TAG', 'PR') }} - -source: - git_url: GHOSTLY_REMOTE - git_tag: GHOSTLY_BRANCH - -build: - number: {{ environ.get('GIT_DESCRIBE_NUMBER', 0) }} - noarch: python - script: {{ PYTHON }} -m pip install . --no-deps --ignore-installed -vv - -requirements: - host: - - biosimspace - - loguru - - pip - - python - - setuptools - - sire - - versioningit - run: - - biosimspace - - loguru - - python - - sire - -test: - script_env: - - SIRE_DONT_PHONEHOME - - SIRE_SILENT_PHONEHOME - requires: - - black == 25 # [linux and x86_64 and py==311] - - pytest - - pytest-black # [linux and x86_64 and py==311] - imports: - - ghostly - source_files: - - src/ghostly - - tests - commands: - - pytest -vvv --color=yes --black src/ghostly # [linux and x86_64 and py==311] - - pytest -vvv --color=yes --import-mode=importlib tests - -about: - home: https://github.com/openbiosim/ghostly - license: GPL-3.0-or-later - license_file: '{{ environ["RECIPE_DIR"] }}/LICENSE' - summary: "Ghost atom bonded term modifications for alchemical free-energy simulations." - dev_url: https://github.com/openbiosim/ghostly - doc_url: https://github.com/openbiosim/ghostly - description: | - Ghostly is a package to perform modification of ghost (dummy) atom bonded - terms for alchemical free-energy calculations, using the approach described - in the paper "Dummy Atoms in Alchemical Free Energy Calculations" by - Fleck et al., JCTC, 2021, 17, 7, 4403-4419. These modifications were - designed to solve two key issues: - - * To ensure that ghost atoms only give a multiplicative contribution to the - partition function, which will cancel when computing double free-energy - differences. - * To avoid spurious coupling between the physical and ghost systems, which - can affect the equilibrium geometry of the physical system. - - To install: - - `conda install -c conda-forge -c openbiosim ghostly` - - To install the development version: - - `conda install -c conda-forge -c openbiosim/label/dev ghostly` - - When updating the development version it is generally advised to - update Sire at the same time: - - `conda install -c conda-forge -c openbiosim/label/dev ghostly sire` - -extra: - recipe-maintainers: - - lohedges diff --git a/src/ghostly/_ghostly.py b/src/ghostly/_ghostly.py index e64641f..5de4552 100644 --- a/src/ghostly/_ghostly.py +++ b/src/ghostly/_ghostly.py @@ -31,7 +31,7 @@ try: from somd2 import _logger -except: +except Exception: from loguru import logger as _logger import platform as _platform @@ -86,6 +86,19 @@ def modify(system, k_hard=100, k_soft=5, optimise_angles=True, num_optimise=10): For technical details, please refer to the original publication: https://pubs.acs.org/doi/10.1021/acs.jctc.0c01328 + + .. todo:: + + To enable rotamer stiffening in ``_check_rotamer_anchors``, add + parameters here and expose them through the CLI: + + - ``stiffen_rotamers`` (bool): enable/disable rotamer stiffening. + Pass as ``stiffen`` to ``_check_rotamer_anchors``. + - ``k_rotamer`` (float): force constant for the replacement cosine + well (kcal/mol). The barrier height is 2 * k_rotamer. + + The ``modifications`` dict will also need a ``"stiffened_dihedrals"`` + key initialised to an empty list for each end state. """ # Check the system is a Sire system. @@ -125,9 +138,6 @@ def modify(system, k_hard=100, k_soft=5, optimise_angles=True, num_optimise=10): } for mol in pert_mols: - # Store the molecule info. - info = mol.info() - # Generate the end state connectivity objects. connectivity0 = _create_connectivity(_morph.link_to_reference(mol)) connectivity1 = _create_connectivity(_morph.link_to_perturbed(mol)) @@ -163,12 +173,16 @@ def modify(system, k_hard=100, k_soft=5, optimise_angles=True, num_optimise=10): else: bridges0[c].append(ghost) # Work out the indices of the other physical atoms that are connected to - # the bridge atoms, sorted by the atom index. + # the bridge atoms, sorted by the atom index. These are "core" physical + # atoms, i.e. they are physical in both end states. physical0 = {} for b in bridges0: physical0[b] = [] for c in connectivity0.connections_to(b): - if not _is_ghost(mol, [c])[0]: + if ( + not _is_ghost(mol, [c])[0] + and not _is_ghost(mol, [c], is_lambda1=True)[0] + ): physical0[b].append(c) for b in physical0: physical0[b].sort(key=lambda x: x.value()) @@ -186,7 +200,10 @@ def modify(system, k_hard=100, k_soft=5, optimise_angles=True, num_optimise=10): for b in bridges1: physical1[b] = [] for c in connectivity1.connections_to(b): - if not _is_ghost(mol, [c], is_lambda1=True)[0]: + if ( + not _is_ghost(mol, [c])[0] + and not _is_ghost(mol, [c], is_lambda1=True)[0] + ): physical1[b].append(c) for b in physical1: physical1[b].sort(key=lambda x: x.value()) @@ -249,6 +266,7 @@ def modify(system, k_hard=100, k_soft=5, optimise_angles=True, num_optimise=10): b, bridges0[b], physical0[b], + connectivity0, modifications, k_hard=k_hard, k_soft=k_soft, @@ -271,6 +289,24 @@ def modify(system, k_hard=100, k_soft=5, optimise_angles=True, num_optimise=10): num_optimise=num_optimise, ) + # Remove any improper dihedrals connecting ghosts to the physical region. + mol = _remove_impropers(mol, ghosts0, modifications, is_lambda1=False) + + # Remove any residual ghost dihedrals not caught by the per-bridge + # junction handlers (cross-bridge and ghost-middle patterns). + mol = _remove_residual_ghost_dihedrals( + mol, ghosts0, modifications, is_lambda1=False + ) + + # Remove any angles where the central atom is ghost and both terminal + # atoms are physical (e.g. B1-G-B2 in ring-breaking topologies). + mol = _remove_ghost_centre_angles(mol, ghosts0, modifications, is_lambda1=False) + + # Check for potential rotamer anchor dihedrals. + mol = _check_rotamer_anchors( + mol, bridges0, physical0, ghosts0, modifications, is_lambda1=False + ) + # Now lambda = 1. for b in bridges1: junction = len(physical1[b]) @@ -304,6 +340,7 @@ def modify(system, k_hard=100, k_soft=5, optimise_angles=True, num_optimise=10): b, bridges1[b], physical1[b], + connectivity1, modifications, k_hard=k_hard, k_soft=k_soft, @@ -328,6 +365,24 @@ def modify(system, k_hard=100, k_soft=5, optimise_angles=True, num_optimise=10): is_lambda1=True, ) + # Remove any improper dihedrals connecting ghosts to the physical region. + mol = _remove_impropers(mol, ghosts1, modifications, is_lambda1=True) + + # Remove any residual ghost dihedrals not caught by the per-bridge + # junction handlers (cross-bridge and ghost-middle patterns). + mol = _remove_residual_ghost_dihedrals( + mol, ghosts1, modifications, is_lambda1=True + ) + + # Remove any angles where the central atom is ghost and both terminal + # atoms are physical (e.g. B1-G-B2 in ring-breaking topologies). + mol = _remove_ghost_centre_angles(mol, ghosts1, modifications, is_lambda1=True) + + # Check for potential rotamer anchor dihedrals. + mol = _check_rotamer_anchors( + mol, bridges1, physical1, ghosts1, modifications, is_lambda1=True + ) + # Update the molecule in the system. system.update(mol) @@ -621,7 +676,7 @@ def _dual( else: _logger.debug(" Dual branch:") - # First, delete all bonded terms between atoms in two ghost branches. + # First, delete all bonded terms between atoms in the two ghost branches. # Get the end state bond functions. angles = mol.property("angle" + suffix) @@ -679,11 +734,11 @@ def _dual( ) # Now treat the ghost branches individually. - for d in ghosts: + for ghost in ghosts: mol = _dual( mol, bridge, - [d], + [ghost], physical, connectivity, modifications, @@ -700,6 +755,7 @@ def _triple( bridge, ghosts, physical, + connectivity, modifications, k_hard=100, k_soft=5, @@ -735,6 +791,9 @@ def _triple( physical : List[sire.legacy.Mol.AtomIdx] The list of physical atoms connected to the bridge atom. + connectivity : sire.legacy.MM.Connectivity + The connectivity of the molecule at the relevant end state. + modifications : dict A dictionary to store details of the modifications made. @@ -922,6 +981,7 @@ def _triple( theta0s[idx] = [] # Perform multiple minimisations to get an average for the theta0 values. + is_error = False for _ in range(num_optimise): # Minimise the molecule. min_mol = _morph.link_to_reference(mol) @@ -930,7 +990,10 @@ def _triple( constraint="none", platform="cpu", ) - minimiser.run() + try: + minimiser.run() + except Exception: + is_error = True # Commit the changes. min_mol = minimiser.commit() @@ -939,9 +1002,15 @@ def _triple( for idx in angle_idxs: try: theta0s[idx].append(min_mol.angles(*idx).sizes()[0].to(_radian)) - except: + except Exception: raise ValueError(f"Could not find optimised angle term: {idx}") + if is_error: + _logger.warning( + "Minimisation failed to converge during angle optimisation." + ) + break + # Compute the mean and standard error. import numpy as _np @@ -1056,52 +1125,8 @@ def _triple( else: new_dihedrals.set(idx0, idx1, idx2, idx3, p.function()) - # Next we modify the angle terms between the remaining physical and - # ghost atoms so that the equilibrium angle is 90 degrees. - new_new_angles = _SireMM.ThreeAtomFunctions(mol.info()) - for p in new_angles.potentials(): - idx0 = info.atom_idx(p.atom0()) - idx1 = info.atom_idx(p.atom1()) - idx2 = info.atom_idx(p.atom2()) - - if ( - idx0 in ghosts - and idx2 in physical[1:] - or idx0 in physical[1:] - and idx2 in ghosts - ): - from math import pi - from sire.legacy.CAS import Symbol - - theta0 = pi / 2.0 - - # Create the new angle function. - amber_angle = _SireMM.AmberAngle(k_hard, theta0) - - # Generate the new angle expression. - expression = amber_angle.to_expression(Symbol("theta")) - - # Set the equilibrium angle to 90 degrees. - new_new_angles.set(idx0, idx1, idx2, expression) - - _logger.debug( - f" Stiffening angle: [{idx0.value()}-{idx1.value()}-{idx2.value()}], " - f"{p.function()} --> {expression}" - ) - - ang_idx = (idx0.value(), idx1.value(), idx2.value()) - modifications[mod_key]["stiffened_angles"].append(ang_idx) - - else: - new_new_angles.set(idx0, idx1, idx2, p.function()) - # Update the molecule. - mol = ( - mol.edit() - .set_property("angle" + suffix, new_new_angles) - .molecule() - .commit() - ) + mol = mol.edit().set_property("angle" + suffix, new_angles).molecule().commit() mol = ( mol.edit() .set_property("dihedral" + suffix, new_dihedrals) @@ -1109,6 +1134,18 @@ def _triple( .commit() ) + # Next we treat the remaining terms as a dual junction. + mol = _dual( + mol, + bridge, + ghosts, + physical[1:], + connectivity, + modifications, + k_hard=k_hard, + is_lambda1=is_lambda1, + ) + # Return the updated molecule. return mol @@ -1296,6 +1333,7 @@ def _higher( bridge, ghosts, physical, + connectivity, modifications, k_hard=k_hard, k_soft=k_soft, @@ -1305,6 +1343,494 @@ def _higher( ) +def _remove_impropers(mol, ghosts, modifications, is_lambda1=False): + """ + Remove improper dihedral terms that bridge the ghost and physical systems. + + Parameters + ---------- + + mol : sire.mol.Molecule + The perturbable molecule. + + ghosts : List[sire.legacy.Mol.AtomIdx] + The list of ghost atoms. + + modifications : dict + A dictionary to store details of the modifications made. + + is_lambda1 : bool, optional + Whether to remove the improper dihedrals at lambda = 1. + + Returns + ------- + + mol : sire.mol.Molecule + The updated molecule. + """ + + # Store the molecular info. + info = mol.info() + + # Get the end state bond functions. + suffix = "1" if is_lambda1 else "0" + impropers = mol.property("improper" + suffix) + + # Initialise a container to store the updated bonded terms. + new_impropers = _SireMM.FourAtomFunctions(mol.info()) + + # Loop over the improper dihedrals. + for p in impropers.potentials(): + idx0 = info.atom_idx(p.atom0()) + idx1 = info.atom_idx(p.atom1()) + idx2 = info.atom_idx(p.atom2()) + idx3 = info.atom_idx(p.atom3()) + + # Remove any improper dihedrals that bridge the ghost and physical systems. + if not all(idx in ghosts for idx in (idx0, idx1, idx2, idx3)) and any( + idx in ghosts for idx in (idx0, idx1, idx2, idx3) + ): + _logger.debug( + f" Removing improper dihedral: [{idx0.value()}-{idx1.value()}-{idx2.value()}-{idx3.value()}], {p.function()}" + ) + dih_idx = (idx0.value(), idx1.value(), idx2.value(), idx3.value()) + dih_idx = ",".join([str(i) for i in dih_idx]) + key = "lambda_1" if is_lambda1 else "lambda_0" + modifications[key]["removed_dihedrals"].append(dih_idx) + else: + new_impropers.set(idx0, idx1, idx2, idx3, p.function()) + + # Set the updated impropers. + mol = ( + mol.edit().set_property("improper" + suffix, new_impropers).molecule().commit() + ) + + # Return the updated molecule. + return mol + + +def _remove_residual_ghost_dihedrals(mol, ghosts, modifications, is_lambda1=False): + r""" + Remove dihedral terms that couple ghost and physical regions but were + not caught by the per-bridge junction handlers. This covers two cases: + + 1. Cross-bridge: both terminal atoms are ghost and both middle atoms + are physical. This arises when two ghost groups have adjacent bridge + atoms. The dihedral DR1-X1-X2-DR2 escapes both junction handlers + because each handler only sees its own ghost group. + + DR1 DR2 + \ / + X1------X2 + / \ + R1 R2 + + Removed dihedral: DR1-X1-X2-DR2 + + 2. Ghost middle: both terminal atoms are physical but at least one + middle atom is ghost. This arises in ring-breaking topologies where + a ghost atom is bonded to two bridge atoms. + + R1 R2 + \ / + X1-DR-X2 + / \ + R3 R4 + + Removed dihedrals: e.g. R1-X1-DR-X2, X1-DR-X2-R2 + + Parameters + ---------- + + mol : sire.mol.Molecule + The perturbable molecule. + + ghosts : List[sire.legacy.Mol.AtomIdx] + The list of ghost atoms at the current end state. + + modifications : dict + A dictionary to store details of the modifications made. + + is_lambda1 : bool, optional + Whether to modify dihedrals at lambda = 1. + + Returns + ------- + + mol : sire.mol.Molecule + The updated molecule. + """ + + # Nothing to do if there are no ghost atoms. + if not ghosts: + return mol + + # Store the molecular info. + info = mol.info() + + # Get the end state property. + if is_lambda1: + mod_key = "lambda_1" + suffix = "1" + else: + mod_key = "lambda_0" + suffix = "0" + + # Get the end state dihedral functions. + dihedrals = mol.property("dihedral" + suffix) + + # Initialise a container to store the updated dihedral functions. + new_dihedrals = _SireMM.FourAtomFunctions(mol.info()) + + # Track whether any modifications were made. + modified = False + + # Loop over the dihedral potentials. + for p in dihedrals.potentials(): + idx0 = info.atom_idx(p.atom0()) + idx1 = info.atom_idx(p.atom1()) + idx2 = info.atom_idx(p.atom2()) + idx3 = info.atom_idx(p.atom3()) + + # Case 1: Both terminals ghost, both middles physical (cross-bridge). + cross_bridge = ( + idx0 in ghosts + and idx3 in ghosts + and idx1 not in ghosts + and idx2 not in ghosts + ) + + # Case 2: Both terminals physical, at least one middle ghost + # (ring-breaking). + ghost_middle = ( + idx0 not in ghosts + and idx3 not in ghosts + and (idx1 in ghosts or idx2 in ghosts) + ) + + if cross_bridge or ghost_middle: + _logger.debug( + f" Removing residual ghost dihedral: " + f"[{idx0.value()}-{idx1.value()}-{idx2.value()}-{idx3.value()}], " + f"{p.function()}" + ) + dih_idx = (idx0.value(), idx1.value(), idx2.value(), idx3.value()) + dih_idx = ",".join([str(i) for i in dih_idx]) + modifications[mod_key]["removed_dihedrals"].append(dih_idx) + modified = True + else: + new_dihedrals.set(idx0, idx1, idx2, idx3, p.function()) + + # Set the updated dihedrals. + if modified: + mol = ( + mol.edit() + .set_property("dihedral" + suffix, new_dihedrals) + .molecule() + .commit() + ) + + # Return the updated molecule. + return mol + + +def _remove_ghost_centre_angles(mol, ghosts, modifications, is_lambda1=False): + r""" + Remove angle terms where the central atom is ghost and both terminal + atoms are physical. These can arise in ring-breaking topologies where + a ghost atom is bonded to two bridge atoms. The per-bridge junction + handlers only catch angles with ghost terminal atoms, not ghost central + atoms. + + R1 R2 + \ / + X1-DR-X2 + / \ + R3 R4 + + Removed angle: X1-DR-X2 + + Parameters + ---------- + + mol : sire.mol.Molecule + The perturbable molecule. + + ghosts : List[sire.legacy.Mol.AtomIdx] + The list of ghost atoms at the current end state. + + modifications : dict + A dictionary to store details of the modifications made. + + is_lambda1 : bool, optional + Whether to modify angles at lambda = 1. + + Returns + ------- + + mol : sire.mol.Molecule + The updated molecule. + """ + + # Nothing to do if there are no ghost atoms. + if not ghosts: + return mol + + # Store the molecular info. + info = mol.info() + + # Get the end state property. + if is_lambda1: + mod_key = "lambda_1" + suffix = "1" + else: + mod_key = "lambda_0" + suffix = "0" + + # Get the end state angle functions. + angles = mol.property("angle" + suffix) + + # Initialise a container to store the updated angle functions. + new_angles = _SireMM.ThreeAtomFunctions(mol.info()) + + # Track whether any modifications were made. + modified = False + + # Loop over the angle potentials. + for p in angles.potentials(): + idx0 = info.atom_idx(p.atom0()) + idx1 = info.atom_idx(p.atom1()) + idx2 = info.atom_idx(p.atom2()) + + # Remove any angle where the central atom is ghost and both + # terminal atoms are physical. + if idx1 in ghosts and idx0 not in ghosts and idx2 not in ghosts: + _logger.debug( + f" Removing ghost centre angle: " + f"[{idx0.value()}-{idx1.value()}-{idx2.value()}], " + f"{p.function()}" + ) + ang_idx = (idx0.value(), idx1.value(), idx2.value()) + ang_idx = ",".join([str(i) for i in ang_idx]) + modifications[mod_key]["removed_angles"].append(ang_idx) + modified = True + else: + new_angles.set(idx0, idx1, idx2, p.function()) + + # Set the updated angles. + if modified: + mol = mol.edit().set_property("angle" + suffix, new_angles).molecule().commit() + + # Return the updated molecule. + return mol + + +def _check_rotamer_anchors( + mol, + bridges, + physical, + ghosts, + modifications, + is_lambda1=False, + stiffen=False, + k_rotamer=50, +): + r""" + Detect and optionally stiffen rotamer anchor dihedrals. + + After ghost modifications, each bridge atom retains anchor dihedral(s) + that constrain the ghost group's rotation. If the bridge--physical bond + is not in a ring and the bridge atom is sp3-hybridised, the anchor + dihedral is likely a rotamer. This can allow the ghost group to flip + between rotameric states at intermediate lambda, degrading convergence + (see Boresch et al., JCTC 2021). + + R1 DR1 + \ / + \ / + X ---DR2 X is sp3, P-X is rotatable + / \ + / \ + R2 DR3 + + Rotamer anchors are always logged as warnings. When ``stiffen=True``, + affected dihedrals (those spanning the rotatable bond with at least one + ghost terminal) are replaced with a single n=1 cosine well: + + V(phi) = k [1 + cos(phi - pi)] + + which has a single minimum at phi = 0 (trans) and a barrier of 2k. + + .. note:: + + Stiffening is not currently enabled. When wiring in, add + ``stiffen_rotamers`` and ``k_rotamer`` parameters to ``modify()`` + and expose them through the CLI. The ``modifications`` dict will + also need a ``"stiffened_dihedrals"`` key initialised to an empty + list for each end state. + + Parameters + ---------- + + mol : sire.mol.Molecule + The perturbable molecule. + + bridges : dict + A dictionary mapping bridge atoms to their ghost neighbours. + + physical : dict + A dictionary mapping bridge atoms to their physical neighbours. + + ghosts : List[sire.legacy.Mol.AtomIdx] + The list of ghost atoms at the current end state. + + modifications : dict + A dictionary to store details of the modifications made. + + is_lambda1 : bool, optional + Whether to check/modify the lambda = 1 end state. + + stiffen : bool, optional + Whether to replace rotamer anchor dihedrals with a stiff cosine + well. If False (default), only log warnings. + + k_rotamer : float, optional + The force constant for the replacement cosine term. The resulting + barrier height is 2 * k_rotamer. (In kcal/mol) Only used when + ``stiffen=True``. The default of 50 is a placeholder and has not + been calibrated against simulation data. It should be benchmarked + before use. + + Returns + ------- + + mol : sire.mol.Molecule + The updated molecule (unchanged if ``stiffen=False``). + """ + + # Nothing to do if there are no bridges. + if not bridges: + return mol + + from rdkit.Chem import HybridizationType + from sire.convert import to_rdkit + + lam = int(is_lambda1) + + # Link the molecule to the desired end state and convert to RDKit. + if is_lambda1: + end_state_mol = _morph.link_to_perturbed(mol) + else: + end_state_mol = _morph.link_to_reference(mol) + + try: + rdmol = to_rdkit(end_state_mol) + except Exception as e: + _logger.warning(f"Failed to convert molecule to RDKit for rotamer check: {e}") + return mol + + # Identify rotatable bridge--physical bond pairs. + rotatable_bonds = set() + for bridge in bridges: + for p in physical[bridge]: + b_idx = bridge.value() + p_idx = p.value() + + rd_bond = rdmol.GetBondBetweenAtoms(p_idx, b_idx) + if rd_bond is None or rd_bond.IsInRing(): + continue + + rd_bridge = rdmol.GetAtomWithIdx(b_idx) + hybridisation = rd_bridge.GetHybridization() + + if hybridisation in ( + HybridizationType.SP3, + HybridizationType.SP3D, + HybridizationType.SP3D2, + ): + rotatable_bonds.add((p, bridge)) + if not stiffen: + _logger.warning( + f"Potential rotamer anchor at {_lam_sym} = {lam}: " + f"bond {p_idx}-{b_idx} is a rotatable sp3 bond. " + f"Surviving anchor dihedrals may allow rotameric " + f"transitions of ghost atoms." + ) + + # If not stiffening, or no rotatable bonds found, return early. + if not stiffen or not rotatable_bonds: + return mol + + # Stiffen the anchor dihedrals spanning the rotatable bonds. + + from math import pi + + from sire.legacy.CAS import Symbol + + # Get the end state property suffix. + if is_lambda1: + mod_key = "lambda_1" + suffix = "1" + else: + mod_key = "lambda_0" + suffix = "0" + + # Store the molecular info. + info = mol.info() + + # Get the end state dihedral functions. + dihedrals = mol.property("dihedral" + suffix) + + # Create the stiff single-well replacement: k[1 + cos(phi - pi)]. + # Minimum at phi = 0 (trans), barrier height = 2k. + replacement = _SireMM.AmberDihedral( + _SireMM.AmberDihPart(k_rotamer, 1, pi) + ).to_expression(Symbol("phi")) + + # Initialise a container to store the updated dihedral functions. + new_dihedrals = _SireMM.FourAtomFunctions(mol.info()) + + modified = False + + for p in dihedrals.potentials(): + idx0 = info.atom_idx(p.atom0()) + idx1 = info.atom_idx(p.atom1()) + idx2 = info.atom_idx(p.atom2()) + idx3 = info.atom_idx(p.atom3()) + + # Check if the central bond (idx1-idx2) is a rotatable bridge bond + # and at least one terminal atom is ghost. + bond_pair = (idx1, idx2) + bond_pair_rev = (idx2, idx1) + has_ghost_terminal = idx0 in ghosts or idx3 in ghosts + + if has_ghost_terminal and ( + bond_pair in rotatable_bonds or bond_pair_rev in rotatable_bonds + ): + _logger.debug( + f" Stiffening rotamer anchor dihedral: " + f"[{idx0.value()}-{idx1.value()}-{idx2.value()}-{idx3.value()}], " + f"{p.function()} --> {replacement}" + ) + new_dihedrals.set(idx0, idx1, idx2, idx3, replacement) + dih_idx = (idx0.value(), idx1.value(), idx2.value(), idx3.value()) + dih_idx = ",".join([str(i) for i in dih_idx]) + modifications[mod_key]["stiffened_dihedrals"].append(dih_idx) + modified = True + else: + new_dihedrals.set(idx0, idx1, idx2, idx3, p.function()) + + if modified: + mol = ( + mol.edit() + .set_property("dihedral" + suffix, new_dihedrals) + .molecule() + .commit() + ) + + return mol + + def _create_connectivity(mol): """ Create a connectivity object for an end state molecule. diff --git a/tests/test_ghostly.py b/tests/test_ghostly.py index a1c52ee..5b7ab8d 100644 --- a/tests/test_ghostly.py +++ b/tests/test_ghostly.py @@ -291,6 +291,219 @@ def test_acetone_to_propenol(): assert str(p.function()) == expression +def test_ejm49_to_ejm31(): + """ + Test ghost atom modifications for the TYK ligands EJM 49 to 31. This is assert + more complex perturbation. + """ + + # Load the system. Here pruned means that the atom mapping has pruned + # atoms where the constraint changes between the end states, which is + # what is used by OpenFE. + mols = sr.load_test_files("ejm49_ejm31_pruned.bss") + + # Store the orginal angles and dihedrals at lambda = 0 and lambda = 1. + angles0 = mols[0].property("angle0") + angles1 = mols[0].property("angle1") + dihedrals0 = mols[0].property("dihedral0") + dihedrals1 = mols[0].property("dihedral1") + improper0 = mols[0].property("improper0") + improper1 = mols[0].property("improper1") + + # Apply the ghost atom modifications. + new_mols, _ = modify(mols) + + # Get the new angles and dihedrals. + new_angles0 = new_mols[0].property("angle0") + new_angles1 = new_mols[0].property("angle1") + new_dihedrals0 = new_mols[0].property("dihedral0") + new_dihedrals1 = new_mols[0].property("dihedral1") + new_improper0 = new_mols[0].property("improper0") + new_improper1 = new_mols[0].property("improper1") + + # The number of angles should remain the same at lambda = 0. + assert angles0.num_functions() == new_angles0.num_functions() + + # The number of dihedrals should be five fewer at lambda = 0. + assert dihedrals0.num_functions() - 5 == new_dihedrals0.num_functions() + + # The number of impropers shoudld be three fewer at lambda = 0. + assert improper0.num_functions() - 3 == new_improper0.num_functions() + + # The number of angles should remain the same at lambda = 1. + assert angles1.num_functions() == new_angles1.num_functions() + + # The number of dihedrals should be four fewer at lambda = 1. + assert dihedrals1.num_functions() - 4 == new_dihedrals1.num_functions() + + # The number of impropers should be six fewer at lambda = 1. + assert improper1.num_functions() - 6 == new_improper1.num_functions() + + # Create dihedral IDs for the missing dihedrals at lambda = 0. + + from sire.legacy.Mol import AtomIdx + + missing_dihedrals0 = [ + (AtomIdx(18), AtomIdx(17), AtomIdx(39), AtomIdx(40)), + (AtomIdx(18), AtomIdx(17), AtomIdx(39), AtomIdx(41)), + (AtomIdx(18), AtomIdx(17), AtomIdx(39), AtomIdx(42)), + (AtomIdx(33), AtomIdx(16), AtomIdx(17), AtomIdx(39)), + (AtomIdx(14), AtomIdx(16), AtomIdx(17), AtomIdx(39)), + ] + + # Store the molecular info. + info = mols[0].info() + + # Check that the missing dihedrals are in the original dihedrals at lambda = 0. + assert ( + all( + check_dihedral(info, dihedrals0.potentials(), *dihedral) + for dihedral in missing_dihedrals0 + ) + == True + ) + + # Check that the missing dihedrals are not in the new dihedrals at lambda = 0. + assert ( + all( + check_dihedral(info, new_dihedrals0.potentials(), *dihedral) + for dihedral in missing_dihedrals0 + ) + == False + ) + + # Create dihedral IDs for the missing dihedrals at lambda = 1. + missing_dihedrals1 = [ + (AtomIdx(18), AtomIdx(17), AtomIdx(20), AtomIdx(21)), + (AtomIdx(18), AtomIdx(17), AtomIdx(20), AtomIdx(25)), + (AtomIdx(20), AtomIdx(17), AtomIdx(16), AtomIdx(33)), + (AtomIdx(14), AtomIdx(16), AtomIdx(17), AtomIdx(20)), + ] + + # Check that the missing dihedrals are in the original dihedrals at lambda = 1. + assert ( + all( + check_dihedral(info, dihedrals1.potentials(), *dihedral) + for dihedral in missing_dihedrals1 + ) + == True + ) + + # Check that the missing dihedrals are not in the new dihedrals at lambda = 1. + assert ( + all( + check_dihedral(info, new_dihedrals1.potentials(), *dihedral) + for dihedral in missing_dihedrals1 + ) + == False + ) + + # Create angle IDs for the modified angles at lambda = 0. + modified_angles0 = [ + (AtomIdx(16), AtomIdx(17), AtomIdx(39)), + (AtomIdx(18), AtomIdx(17), AtomIdx(39)), + ] + + # Functional form of the modified angles. + expression = "100 [theta - 1.5708]^2" + + # Check that the original angles don't have the modified functional form. + for p in angles0.potentials(): + idx0 = info.atom_idx(p.atom0()) + idx1 = info.atom_idx(p.atom1()) + idx2 = info.atom_idx(p.atom2()) + + if (idx0, idx1, idx2) in modified_angles0: + assert str(p.function()) != expression + + # Check that the modified angles have the correct functional form. + for p in new_angles0.potentials(): + idx0 = info.atom_idx(p.atom0()) + idx1 = info.atom_idx(p.atom1()) + idx2 = info.atom_idx(p.atom2()) + + if (idx0, idx1, idx2) in modified_angles0: + assert str(p.function()) == expression + + # Create angle IDs for the modified angles at lambda = 1. + modified_angles1 = [ + (AtomIdx(16), AtomIdx(17), AtomIdx(20)), + (AtomIdx(18), AtomIdx(17), AtomIdx(20)), + ] + + # Check that the original angles don't have the modified functional form. + for p in angles1.potentials(): + idx0 = info.atom_idx(p.atom0()) + idx1 = info.atom_idx(p.atom1()) + idx2 = info.atom_idx(p.atom2()) + + if (idx0, idx1, idx2) in modified_angles1: + assert str(p.function()) != expression + + # Check that the modified angles have the correct functional form. + for p in new_angles1.potentials(): + idx0 = info.atom_idx(p.atom0()) + idx1 = info.atom_idx(p.atom1()) + idx2 = info.atom_idx(p.atom2()) + + if (idx0, idx1, idx2) in modified_angles1: + assert str(p.function()) == expression + + # Create improper IDs for the missing impropers at lambda = 0. + missing_impropers0 = [ + (AtomIdx(17), AtomIdx(16), AtomIdx(18), AtomIdx(39)), + (AtomIdx(16), AtomIdx(39), AtomIdx(18), AtomIdx(17)), + (AtomIdx(17), AtomIdx(39), AtomIdx(16), AtomIdx(18)), + ] + + # Check that the missing impropers are in the original impropers at lambda = 0. + assert ( + all( + check_improper(info, improper0.potentials(), *improper) + for improper in missing_impropers0 + ) + == True + ) + + # Check that the missing impropers are not in the new impropers at lambda = 0. + assert ( + all( + check_improper(info, new_improper0.potentials(), *improper) + for improper in missing_impropers0 + ) + == False + ) + + # Create improper IDs for the missing impropers at lambda = 1. + missing_impropers1 = [ + (AtomIdx(17), AtomIdx(25), AtomIdx(21), AtomIdx(20)), + (AtomIdx(17), AtomIdx(20), AtomIdx(16), AtomIdx(18)), + (AtomIdx(16), AtomIdx(20), AtomIdx(18), AtomIdx(17)), + (AtomIdx(17), AtomIdx(20), AtomIdx(16), AtomIdx(18)), + (AtomIdx(20), AtomIdx(25), AtomIdx(17), AtomIdx(21)), + (AtomIdx(16), AtomIdx(20), AtomIdx(18), AtomIdx(17)), + (AtomIdx(20), AtomIdx(17), AtomIdx(21), AtomIdx(25)), + ] + + # Check that the missing impropers are in the original impropers at lambda = 1. + assert ( + all( + check_improper(info, improper1.potentials(), *improper) + for improper in missing_impropers1 + ) + == True + ) + + # Check that the missing impropers are not in the new impropers at lambda = 1. + assert ( + all( + check_improper(info, new_improper1.potentials(), *improper) + for improper in missing_impropers1 + ) + == False + ) + + def check_angle(info, potentials, idx0, idx1, idx2): """ Check if an angle potential is in a list of potentials. @@ -322,3 +535,20 @@ def check_dihedral(info, potentials, idx0, idx1, idx2, idx3): return True return False + + +def check_improper(info, potentials, idx0, idx1, idx2, idx3): + """ + Check if an improper potential is in a list of potentials. + """ + + for p in potentials: + if ( + idx0 == info.atom_idx(p.atom0()) + and idx1 == info.atom_idx(p.atom1()) + and idx2 == info.atom_idx(p.atom2()) + and idx3 == info.atom_idx(p.atom3()) + ): + return True + + return False