diff --git a/.github/workflows/devel.yaml b/.github/workflows/devel.yaml index d25fb76..e45d9e1 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: somd2-build defaults: @@ -32,30 +30,43 @@ 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: somd2_build - miniforge-version: latest -# - - name: Clone the devel branch - run: git clone -b devel https://github.com/openbiosim/somd2 -# - - name: Setup Conda - run: conda install -y -c conda-forge boa anaconda-client packaging -# - - name: Update Conda recipe - run: python ${{ github.workspace }}/somd2/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 }}/somd2/recipes/somd2 -# - - name: Upload Conda package - run: python ${{ github.workspace }}/somd2/actions/upload_package.py + fetch-depth: 0 + # + - name: Compute version info + shell: bash + run: python actions/update_recipe.py + # + - name: Create sdist + run: pip install build && python -m build --sdist && mv dist/*.tar.gz somd2-source.tar.gz + working-directory: ${{ github.workspace }} + # + - name: Install pixi + uses: prefix-dev/setup-pixi@v0.9.4 + with: + run-install: false + # + - name: Install rattler-build + shell: bash + run: pixi global install rattler-build + # + - 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/somd2" -c conda-forge -c openbiosim/label/dev --variant-config "${{ github.workspace }}/python_variant.yaml" + # + - name: Install anaconda-client + shell: bash + run: python -m pip install anaconda-client + # + - name: Upload package + shell: bash + run: python 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 088186e..354264e 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: somd2-build defaults: @@ -33,30 +33,45 @@ 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: somd2_build - miniforge-version: latest -# - - name: Clone the main branch - run: git clone -b main https://github.com/openbiosim/somd2 -# - - name: Setup Conda - run: conda install -y -c conda-forge boa anaconda-client packaging -# - - name: Update Conda recipe - run: python ${{ github.workspace }}/somd2/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 }}/somd2/recipes/somd2 -# - - name: Upload Conda package - run: python ${{ github.workspace }}/somd2/actions/upload_package.py + ref: main + fetch-depth: 0 + # + - name: Compute version info + shell: bash + run: python actions/update_recipe.py + # + - name: Create sdist + run: pip install build && python -m build --sdist && mv dist/*.tar.gz somd2-source.tar.gz + working-directory: ${{ github.workspace }} + # + - name: Install pixi + uses: prefix-dev/setup-pixi@v0.9.4 + with: + run-install: false + # + - name: Install rattler-build + shell: bash + run: pixi global install rattler-build + # + - 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/somd2" -c conda-forge -c openbiosim/label/main --variant-config "${{ github.workspace }}/python_variant.yaml" + # + - name: Install anaconda-client + shell: bash + run: python -m pip install anaconda-client + if: github.event.inputs.upload_packages == 'true' + # + - name: Upload package + shell: bash + run: python 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 6191ca5..1069e0a 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: somd2-build defaults: @@ -33,31 +31,39 @@ jobs: env: SIRE_DONT_PHONEHOME: 1 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: somd2_build - miniforge-version: latest -# - - name: Clone the feature branch - run: git clone -b ${{ github.head_ref }} --single-branch https://github.com/${{ env.REPO }} somd2 -# - - name: Setup Conda - run: conda install -y -c conda-forge boa anaconda-client packaging -# - - name: Update Conda recipe - run: python ${{ github.workspace }}/somd2/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 + shell: bash + run: python actions/update_recipe.py + # + - name: Create sdist + run: pip install build && python -m build --sdist && mv dist/*.tar.gz somd2-source.tar.gz + working-directory: ${{ github.workspace }} + # + - name: Install pixi + uses: prefix-dev/setup-pixi@v0.9.4 + with: + run-install: false + # + - name: Install rattler-build + shell: bash + run: pixi global install rattler-build + # + - 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 }}/somd2/recipes/somd2 -# - - name: Build Conda package using conda build using dev channel + shell: bash + run: rattler-build build --recipe "${{ github.workspace }}/recipes/somd2" -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 }}/somd2/recipes/somd2 + shell: bash + run: rattler-build build --recipe "${{ github.workspace }}/recipes/somd2" -c conda-forge -c openbiosim/label/dev --variant-config "${{ github.workspace }}/python_variant.yaml" diff --git a/.gitignore b/.gitignore index e1a8d07..0084085 100644 --- a/.gitignore +++ b/.gitignore @@ -20,7 +20,6 @@ setup.err dist/ build/ somd2.egg-info -src/somd2/_version.py # Test output. output.yaml @@ -35,5 +34,5 @@ output.yaml # VSCode config .vscode/ -# Conda recipe (it is auto-generated) -recipes/somd2/meta.yaml +# Auto-generated version file +src/somd2/_version.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..0043ca4 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,23 @@ +files: ^(src|tests)/ +exclude: ^tests/(input|output)/ + +repos: + # General file quality checks + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-added-large-files + args: [--maxkb=1000] # Prevent files larger than 1MB + - id: check-merge-conflict + + # Python formatting and linting + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.4 + hooks: + # Run the formatter + - id: ruff-format + # Run the linter (optional - remove if too strict) + - id: ruff + args: [--fix, --exit-zero] # Auto-fix but don't block commits diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..96737d3 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +graft tests diff --git a/README.md b/README.md index f8174c8..d5ababc 100644 --- a/README.md +++ b/README.md @@ -9,35 +9,66 @@ simulations. Built on top of [Sire](https://github.com/OpenBioSim/sire) and [Ope ## Installation -First create a conda environment using the provided environment file: +### Conda package + +Install `somd2` directly from the `openbiosim` channel: ``` -conda env create -f environment.yaml +conda install -c conda-forge -c openbiosim somd2 ``` -(We recommend using [Miniforge](https://github.com/conda-forge/miniforge).) +Or, for the development version: -> [!NOTE] -> On macOS, you will need to use the `environment_macos.yaml` file instead. +``` +conda install -c conda-forge -c openbiosim/label/dev somd2 +``` -Now install `somd2` into the environment: +### 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), +[BioSimSpace](https://github.com/OpenBioSim/biosimspace), +[Ghostly](https://github.com/OpenBioSim/ghostly), and +[Loch](https://github.com/OpenBioSim/loch)): ``` -conda activate somd2 -pip install --editable . +git clone https://github.com/openbiosim/somd2 +cd somd2 +pixi install +pixi shell +pip install -e . ``` -Alternatively, to install into an existing conda environment: +### 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 somd2 +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), +[Ghostly](https://github.com/OpenBioSim/ghostly), and +[Loch](https://github.com/OpenBioSim/loch): ``` -conda install -c conda-forge -c openbiosim/label/dev somd2 +pip install -e /path/to/biosimspace +pip install -e /path/to/ghostly +pip install -e /path/to/loch +``` + +Then install `somd2` into the environment: + ``` +pip install -e . +``` + +### Testing You should now have a `somd2` executable in your path. To test, run: @@ -45,6 +76,24 @@ You should now have a `somd2` executable in your path. To test, run: somd2 --help ``` +## Development + +Pre-commit hooks are used to ensure consistent code formatting and linting. +To set up pre-commit in your development environment: + +``` +pixi shell -e dev +pre-commit install +``` + +This will run [ruff](https://docs.astral.sh/ruff/) formatting and linting +checks automatically on each commit. To run the checks manually against all +files: + +``` +pre-commit run --all-files +``` + ## Usage In order to run an alchemical free-energy simulation you will need to diff --git a/actions/update_recipe.py b/actions/update_recipe.py index ac3ff96..629369a 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", "somd2") - -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("SOMD2_REMOTE", remote) - line = line.replace("SOMD2_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", "somd2", "_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 799638c..22547ad 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 somd2/actions/) +# Go up one directory to get the source directory. srcdir = os.path.dirname(os.path.dirname(script)) print(f"SOMD2 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", "somd2_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 -somd2_pkg = glob.glob(os.path.join(conda_bld, "*-*", "somd2-*.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", "somd2_build", "conda-bld") + packages = glob.glob( + os.path.join(conda_bld, "**", "somd2-*.tar.bz2"), recursive=True + ) -if len(somd2_pkg) == 0: +if not packages: print("No somd2 packages to upload?") sys.exit(-1) -packages = somd2_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 e509e02..0000000 --- a/environment.yaml +++ /dev/null @@ -1,15 +0,0 @@ -name: somd2 - -channels: - - conda-forge - - openbiosim/label/dev - -dependencies: - - biosimspace - - ghostly - - filelock - - loch - - loguru - - numba - - nvidia-ml-py - - versioningit diff --git a/environment_macos.yaml b/environment_macos.yaml deleted file mode 100644 index 19083a3..0000000 --- a/environment_macos.yaml +++ /dev/null @@ -1,13 +0,0 @@ -name: somd2 - -channels: - - conda-forge - - openbiosim/label/dev - -dependencies: - - biosimspace - - ghostly - - filelock - - loguru - - numba - - versioningit diff --git a/pixi.toml b/pixi.toml new file mode 100644 index 0000000..7a3ffa4 --- /dev/null +++ b/pixi.toml @@ -0,0 +1,28 @@ +[workspace] +name = "somd2" +channels = ["conda-forge", "openbiosim/label/dev"] +# No Windows - depends on loch which requires pycuda/pyopencl +platforms = ["linux-64", "osx-arm64"] + +[dependencies] +python = ">=3.10" +biosimspace = "*" +ghostly = "*" +loch = "*" +loguru = "*" +numba = "*" +nvidia-ml-py = "*" + +[feature.test.dependencies] +pytest = "*" +black = "*" + +[feature.lint.dependencies] +pre-commit = "*" +rattler-build = "*" +ruff = "*" + +[environments] +default = [] +test = ["test"] +dev = ["test", "lint"] diff --git a/pyproject.toml b/pyproject.toml index fd326a6..10a39fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,3 +35,10 @@ distance-dirty = "{base_version}+{distance}.{vcs}{rev}.dirty" [tool.versioningit.write] file = "src/somd2/_version.py" + +[tool.ruff.lint] +ignore = ["E402"] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["F841"] + diff --git a/recipes/somd2/conda_build_config.yaml b/recipes/somd2/conda_build_config.yaml deleted file mode 100644 index 3e8e203..0000000 --- a/recipes/somd2/conda_build_config.yaml +++ /dev/null @@ -1,3 +0,0 @@ -pin_run_as_build: - sire: - max_pin: x.x diff --git a/recipes/somd2/recipe.yaml b/recipes/somd2/recipe.yaml new file mode 100644 index 0000000..0217074 --- /dev/null +++ b/recipes/somd2/recipe.yaml @@ -0,0 +1,55 @@ +context: + name: somd2 + +package: + name: ${{ name }} + version: ${{ env.get('GIT_DESCRIBE_TAG', default='PR') }} + +source: + path: ../../somd2-source.tar.gz + +build: + number: ${{ env.get('GIT_DESCRIBE_NUMBER', default='0') }} + script: python -m pip install . --no-deps --ignore-installed -vv + +requirements: + host: + - pip + - python + - setuptools + - versioningit + run: + - biosimspace + - ghostly + - loch + - loguru + - numba + - nvidia-ml-py + - python + +tests: + - if: linux + then: + - python: + imports: + - somd2 + - script: + - PYTHONPATH=. pytest -vvv --color=yes --import-mode=importlib ./tests + files: + source: + - tests/ + requirements: + run: + - pytest + +about: + homepage: https://github.com/openbiosim/somd2 + license: GPL-3.0-or-later + license_file: LICENSE + summary: "GPU accelerated free-energy perturbation simulation engine." + repository: https://github.com/openbiosim/somd2 + documentation: https://github.com/openbiosim/somd2 + +extra: + recipe-maintainers: + - lohedges diff --git a/recipes/somd2/template.yaml b/recipes/somd2/template.yaml deleted file mode 100644 index 27c3f78..0000000 --- a/recipes/somd2/template.yaml +++ /dev/null @@ -1,80 +0,0 @@ -{% set name = "somd2" %} - -package: - name: {{ name }} - version: {{ environ.get('GIT_DESCRIBE_TAG', 'PR') }} - -source: - git_url: SOMD2_REMOTE - git_tag: SOMD2_BRANCH - -build: - number: {{ environ.get('GIT_DESCRIBE_NUMBER', 0) }} - script: {{ PYTHON }} -m pip install . --no-deps --ignore-installed -vv - -requirements: - host: - - biosimspace - - ghostly - - loch - - loguru - - pip - - python - - numba - - nvidia-ml-py # [linux] - - setuptools - - sire - - versioningit - run: - - biosimspace - - ghostly - - loch - - loguru - - numba - - nvidia-ml-py # [linux] - - 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: - - somd2 # [linux] - source_files: - - src/somd2 - - tests - commands: - - pytest -vvv --color=yes --black src/somd2 # [linux and x86_64 and py==311] - - pytest -vvv --color=yes --import-mode=importlib tests # [linux] - -about: - home: https://github.com/openbiosim/somd2 - license: GPL-3.0-or-later - license_file: '{{ environ["RECIPE_DIR"] }}/LICENSE' - summary: "GPU accelerated free-energy pertubation simulation engine" - dev_url: https://github.com/openbiosim/somd2 - doc_url: https://github.com/openbiosim/somd2 - description: | - somd2 is an open-source GPU accelerated molecular dynamics engine for - alchemical free-energy calculations. Built on top of Sire, BioSimSpace, - and OpenMM. - - `conda install -c conda-forge -c openbiosim somd2` - - To install the development version: - - `conda install -c conda-forge -c openbiosim/label/dev somd2` - - 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 somd2 sire` - -extra: - recipe-maintainers: - - lohedges diff --git a/setup.py b/setup.py deleted file mode 100644 index 6068493..0000000 --- a/setup.py +++ /dev/null @@ -1,3 +0,0 @@ -from setuptools import setup - -setup() diff --git a/src/somd2/_utils/_somd1.py b/src/somd2/_utils/_somd1.py index aa379f7..d25b795 100644 --- a/src/somd2/_utils/_somd1.py +++ b/src/somd2/_utils/_somd1.py @@ -548,7 +548,7 @@ def make_compatible(system, fix_perturbable_zero_sigmas=False): for idx0 in impropers0_idx.keys(): if idx1.equivalent(idx0): # Don't store duplicates. - if not idx0 in impropers_shared_idx.keys(): + if idx0 not in impropers_shared_idx.keys(): impropers_shared_idx[idx1] = ( impropers0_idx[idx0], impropers1_idx[idx1], @@ -659,7 +659,6 @@ def reconstruct_system(system): # Loop over all perturbable molecules. for mol in pert_mols: - # Delete any AmberParams properties. try: cursor = mol.cursor() diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index 52babac..49ce7a0 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -652,10 +652,21 @@ def as_dict(self, sire_compatible=False): self._charge_scale_factor ): d["lambda_schedule"] = "charge_scaled_morph" + else: + d["lambda_schedule"] = self._serialise_object(self.lambda_schedule) + + # Serialise restraints. + if self.restraints is not None: + d["restraints"] = [ + self._serialise_object(restraint) for restraint in self.restraints + ] # Use the path for the perturbed_system option, since the system # isn't serializable. - if self.perturbed_system is not None: + if ( + self.perturbed_system is not None + and self._perturbed_system_file is not None + ): d["perturbed_system"] = str(self._perturbed_system_file) d.pop("perturbed_system_file", None) @@ -981,14 +992,21 @@ def lambda_schedule(self, lambda_schedule): if isinstance(lambda_schedule, str): # Strip whitespace and convert to lower case. lambda_schedule = lambda_schedule.strip().lower() - if lambda_schedule not in self._choices["lambda_schedule"]: - raise ValueError( - f"Lambda schedule not recognised. Valid lambda schedules are: {self._choices['lambda_schedule']}" - ) if lambda_schedule == "standard_morph": self._lambda_schedule = _LambdaSchedule.standard_morph() elif lambda_schedule == "charge_scaled_morph": self._lambda_schedule = _LambdaSchedule.charge_scaled_morph(0.2) + else: + try: + self._lambda_schedule = self._deserialise_object( + lambda_schedule + ) + except Exception: + raise ValueError( + "Unable to deserialise 'lambda_schedule'. Ensure that this is a " + "hex string representation of a valid LambdaSchedule object, or " + f"one of the following strings: {', '.join(self._choices['lambda_schedule'])}" + ) else: self._lambda_schedule = lambda_schedule else: @@ -1091,12 +1109,27 @@ def restraints(self, restraints): restraints = [restraints] # Check that all restraints are of the correct type. + deserialised_restraints = [] for restraint in restraints: - if not isinstance(restraint, _sr.mm._MM.Restraints): + if isinstance(restraint, _sr.mm._MM.Restraints): + continue + elif isinstance(restraint, str): + try: + restraint = self._deserialise_object(restraint) + except Exception: + raise ValueError( + "Unable to deserialise restraint. Ensure that this " + "is a hex string representation of a valid sire.mm._MM.Restraints object." + ) + deserialised_restraints.append(restraint) + else: raise ValueError( "'restraints' must be a sire.mm._MM.Restraints object, or a list of these objects." ) + if len(deserialised_restraints) > 0: + restraints = deserialised_restraints + self._restraints = restraints @property @@ -1456,7 +1489,6 @@ def platform(self): @platform.setter def platform(self, platform): import os as _os - import sys as _sys if not isinstance(platform, str): raise TypeError("'platform' must be of type 'str'") @@ -2080,6 +2112,63 @@ def overwrite(self, overwrite): raise ValueError("'overwrite' must be of type 'bool'") self._overwrite = overwrite + @staticmethod + def _serialise_object(obj): + """ + Internal method to serialise a Sire object to a hex string representation + for storage in the YAML config file. + + Parameters + ---------- + + obj: object + The Sire object to serialise. + + Returns + -------- + + hex: + The hex string representation of the Sire object. + """ + + from sire.stream import save + from sire.legacy.Qt import QByteArray + + try: + hex = QByteArray(save(obj)).to_hex().data() + except Exception as e: + raise ValueError(f"Unable to serialise object: {e}") + + return hex + + @staticmethod + def _deserialise_object(hex): + """ + Internal method to deserialise a Sire object from a hex string representation. + + Parameters + ---------- + + hex: str + The hex string representation of the Sire object. + + Returns + ------- + + obj: + The deserialised Sire object. + """ + from sire.stream import load + from sire.legacy.Qt import QByteArray + + try: + hex_byte_arrary = QByteArray.from_raw_data(hex, len(hex)) + obj = load(QByteArray.from_hex(hex_byte_arrary)) + except Exception as e: + raise ValueError(f"Unable to deserialise object: {e}") + + return obj + @classmethod def _create_parser(cls): """ diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 14912eb..7d3469b 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -402,8 +402,8 @@ def __init__(self, system, config): if len(self._config.rest2_scale) != len(self._lambda_energy): msg = f"Length of 'rest2_scale' must match the number of {_lam_sym} values." if is_missing: - msg += f"If you have omitted some 'lambda_values` from `lambda_energy`, please " - f"add them to `lambda_energy`, along with the corresponding `rest2_scale` values." + msg += "If you have omitted some 'lambda_values` from `lambda_energy`, please " + "add them to `lambda_energy`, along with the corresponding `rest2_scale` values." _logger.error(msg) raise ValueError(msg) # Make sure the end states are close to 1.0. @@ -429,7 +429,6 @@ def __init__(self, system, config): # Make sure the REST2 selection is valid. if self._config.rest2_selection is not None: - try: atoms = _sr.mol.selection_to_atoms( self._system, self._config.rest2_selection @@ -528,7 +527,19 @@ def __init__(self, system, config): self._is_restart = False self._cleanup() - # Save config whenever 'configure' is called to keep it up to date. + if self._config.replica_exchange and self._config.perturbed_system is not None: + # Check whether the perturbed system was loaded from file. If not + # we need to save to the output directory and update the config to + # point to the new file. + if self._config._perturbed_system_file is None: + filename = str( + _Path(self._config.output_directory) / "perturbed_system.s3" + ) + _sr.stream.save(self._config.perturbed_system, filename) + self._config._perturbed_system_file = filename + _logger.info(f"Saving perturbed system to {filename}") + + # Write YAML configuration file to the output directory. if self._config.write_config: _dict_to_yaml( self._config.as_dict(), @@ -1345,6 +1356,70 @@ def _compare_configs(config1, config2): v1 = config1[key] v2 = config2[key] + # None config options stored as a Sire property are converted + # to False, so None and Fasle are equivalent for the purposes of + # comparison. + if v1 is None and not v2: + continue + if v2 is None and not v1: + continue + + # Early exit equivalence check. + if v1 == v2: + continue + + # Custom lambda schedules are stored as a hexademical string of + # serialised object. We need to deserialise them before comparison. + if key == "lambda_schedule": + # Standard schedules are stored as strings, so we can compare these directly. + if v1 == v2: + continue + else: + try: + v1 = _Config._deserialise_object(v1) + except Exception as e: + raise ValueError( + f"Unable to deserialise lambda schedule from config1: {str(e)}" + ) + try: + v2 = _Config._deserialise_object(v2) + except Exception as e: + raise ValueError( + f"Unable to deserialise lambda schedule from config2: {str(e)}" + ) + if v1 != v2: + raise ValueError( + f"{key} has changed since the last run. This is not " + "allowed when using the restart option." + ) + else: + continue + + # Restraints are stored as a list of hexadecimal strings of serialised objects. + # We need to deserialise them before comparison. + elif key == "restraints": + if v1 and v2: + for r1, r2 in zip(v1, v2): + try: + r1 = _Config._deserialise_object(r1) + except Exception as e: + raise ValueError( + f"Unable to deserialise restraint from config1: {str(e)}" + ) + try: + r2 = _Config._deserialise_object(r2) + except Exception as e: + raise ValueError( + f"Unable to deserialise restraint from config2: {str(e)}" + ) + if r1 != r2: + raise ValueError( + f"{key} has changed since the last run. This is not " + "allowed when using the restart option." + ) + else: + continue + # Convert GeneralUnits to strings for comparison. if isinstance(v1, _GeneralUnit): v1 = str(v1) @@ -1354,14 +1429,14 @@ def _compare_configs(config1, config2): # Convert Sire containers to lists for comparison. try: v1 = v1.to_list() - except: + except Exception: pass try: v2 = v2.to_list() - except: + except Exception: pass - if (v1 == None and v2 == False) or (v2 == None and v1 == False): + if (v1 is None and v2 == False) or (v2 is None and v1 == False): continue # The GCMC frequency will be automaticall set if None. elif key == "gcmc_frequency" and v1 is None: diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index fb42401..cdc1851 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -461,7 +461,6 @@ def save_openmm_state(self, index): index: int The index of the replica. """ - from openmm.unit import angstrom # Get the current OpenMM state. state = ( @@ -1039,8 +1038,7 @@ def run(self): for j in range(num_checkpoint_batches): # Get the indices of the replicas in this batch. replicas = replica_list[ - j - * num_checkpoint_workers : (j + 1) + j * num_checkpoint_workers : (j + 1) * num_checkpoint_workers ] with ThreadPoolExecutor(max_workers=num_workers) as executor: @@ -1063,8 +1061,7 @@ def run(self): for j in range(num_checkpoint_batches): # Get the indices of the replicas in this batch. replicas = replica_list[ - j - * num_checkpoint_workers : (j + 1) + j * num_checkpoint_workers : (j + 1) * num_checkpoint_workers ] with ThreadPoolExecutor(max_workers=num_workers) as executor: diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index 12a637c..931b534 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -155,7 +155,7 @@ def _initialise_gpu_devices(num_devices, oversubscription_factor=1): Returns ------- - devices : [(str, int)] + devices: [(str, int)] List of available device numbers with oversubscription factor. """ devices = [] @@ -658,7 +658,6 @@ def generate_lam_vals(lambda_base, increment=0.001): # Run the simulation, checkpointing in blocks. if checkpoint_frequency.value() > 0.0: - # Calculate the number of blocks and the remainder time. frac = (time / checkpoint_frequency).value() diff --git a/tests/runner/test_config.py b/tests/runner/test_config.py index cfb14be..05a2f0b 100644 --- a/tests/runner/test_config.py +++ b/tests/runner/test_config.py @@ -1,4 +1,3 @@ -import pytest import tempfile import sire as sr diff --git a/tests/runner/test_lambda_values.py b/tests/runner/test_lambda_values.py index ec6267f..ca63d7a 100644 --- a/tests/runner/test_lambda_values.py +++ b/tests/runner/test_lambda_values.py @@ -1,9 +1,7 @@ from pathlib import Path import tempfile -import pytest -import sire as sr from somd2.runner import Runner from somd2.config import Config diff --git a/tests/runner/test_repex.py b/tests/runner/test_repex.py index a21eb9d..9336053 100644 --- a/tests/runner/test_repex.py +++ b/tests/runner/test_repex.py @@ -17,7 +17,6 @@ def test_repex_output(ethane_methanol): Validate that repex specific simulation output is generated. """ with tempfile.TemporaryDirectory() as tmpdir: - config = { "runtime": "12fs", "restart": False, @@ -92,7 +91,6 @@ def test_rest2_scale(ethane_methanol, rest2_scale, is_valid): """Validate the REST2 scale factor handling.""" with tempfile.TemporaryDirectory() as tmpdir: - config = { "runtime": "12fs", "restart": False, @@ -130,7 +128,6 @@ def test_rest2_selection(ethane_methanol, rest2_selection, is_valid): """Validate the REST2 selection handling.""" with tempfile.TemporaryDirectory() as tmpdir: - config = { "runtime": "12fs", "restart": False, diff --git a/tests/runner/test_restart.py b/tests/runner/test_restart.py index 5f9639d..bfb5fd0 100644 --- a/tests/runner/test_restart.py +++ b/tests/runner/test_restart.py @@ -228,3 +228,50 @@ def test_restart(mols, request): # Load the new checkpoint file and make sure the restart fails with pytest.raises(ValueError): runner_badconfig = Runner(mols, Config(**config_new)) + + +def test_restart_custom_schedule(ethane_methanol): + """ + Test that a restart works when using a non-standard lambda schedule. + """ + mols = ethane_methanol.clone() + schedule = sr.cas.LambdaSchedule.standard_decouple() + + with tempfile.TemporaryDirectory() as tmpdir: + config = { + "runtime": "12fs", + "restart": False, + "output_directory": tmpdir, + "energy_frequency": "4fs", + "checkpoint_frequency": "4fs", + "frame_frequency": "4fs", + "lambda_schedule": schedule, + "platform": "CPU", + "max_threads": 1, + "num_lambda": 2, + } + + # Instantiate a runner using the config defined above. + runner = Runner(mols, Config(**config)) + + del runner + + config_new = { + "runtime": "24fs", + "restart": True, + "output_directory": tmpdir, + "energy_frequency": "4fs", + "checkpoint_frequency": "4fs", + "frame_frequency": "4fs", + "lambda_schedule": schedule, + "platform": "CPU", + "max_threads": 1, + "num_lambda": 2, + "overwrite": True, + "log_level": "DEBUG", + } + + runner2 = Runner(mols, Config(**config_new)) + + # Run the simulation. + runner2.run()